Add anonymous OOBE gateway grants
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

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:
Rocky
2026-08-21 22:55:46 +08:00
parent edd0d9feca
commit 0d236f57fb
32 changed files with 2202 additions and 61 deletions
@@ -131,6 +131,11 @@ import com.osglab.account.features.inviteweb.InviteWebConfig
import com.osglab.account.features.inviteweb.InviteOpenRecorder
import com.osglab.account.features.inviteweb.ReferralLookupPort
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
import com.osglab.account.features.oobe.ExposedOobeRepository
import com.osglab.account.features.oobe.OobeGrantService
import com.osglab.account.features.oobe.OobeRepository
import com.osglab.account.features.oobe.OobeTokenSettings
import com.osglab.account.features.oobe.oobeRoutes
import com.osglab.account.features.referrals.routes.referralRoutes
import com.osglab.account.features.referrals.services.ReferralOperations
import com.osglab.account.features.referrals.services.ReferralService
@@ -329,6 +334,7 @@ fun Application.module() {
healthRoutes(koin.get())
rateLimit(AUTH_RATE_LIMIT) {
authRoutes(koin.get())
oobeRoutes(koin.get())
}
rateLimit(ACCOUNT_RATE_LIMIT) {
accountRoutes(koin.get())
@@ -652,8 +658,27 @@ fun accountServerModule(config: AppConfig): Module = module {
maximumGrantLifetime = Duration.ofDays(config.session.gatewayGrantDays),
)
}
single<OobeRepository> { ExposedOobeRepository(get()) }
single {
OobeTokenSettings(
issuer = config.session.issuer,
audience = "${config.session.audience}-gateway",
accessTokenHmacSecret = deriveGatewaySecret(
config.session.hmacSecret,
"oobe-gateway-access",
),
refreshTokenHmacSecret = deriveGatewaySecret(
config.session.hmacSecret,
"oobe-gateway-refresh",
),
)
}
single { OobeGrantService(get(), get(), get()) }
single { GatewayGrantService(get(), get()) }
single<GatewayAccessTokenPort> { GatewayBearerIdentity(get()) }
single<GatewayAccessTokenPort> {
val oobeGrants = get<OobeGrantService>()
GatewayBearerIdentity(get(), oobeGrants::authenticate)
}
single<CreditReservationPort> {
CreditReservationAdapter(
creditService = get(),
@@ -664,7 +689,7 @@ fun accountServerModule(config: AppConfig): Module = module {
single {
ProviderCatalog(configuredProviders(config, get()))
}
single { GatewayService(get(), get(), get(), get(), get()) }
single { GatewayService(get(), get(), get(), get(), get(), get()) }
single { GatewayReconciliationService(get(), get()) }
single {
InviteWebConfig(
@@ -148,6 +148,10 @@ data class AppConfig(
if (production) "production" else "development",
),
),
allowDevelopmentAppAttest = config.booleanOrDefault(
"app.integrity.allowDevelopmentAppAttest",
false,
),
challengeLifetimeSeconds = config.positiveLong(
"app.integrity.challengeLifetimeSeconds",
300,
@@ -449,6 +453,7 @@ data class IntegrityConfig(
val deviceCheckPolicy: IntegrityPolicy,
val appAttestPolicy: IntegrityPolicy,
val appleEnvironment: AppleServiceEnvironment = AppleServiceEnvironment.DEVELOPMENT,
val allowDevelopmentAppAttest: Boolean = false,
val challengeLifetimeSeconds: Long = 300,
val appAttestTeamId: String = APP_ATTEST_TEAM_ID,
val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID,
@@ -31,6 +31,26 @@ enum class GatewayRequestPurpose {
OOBE,
}
enum class GatewaySubjectType {
ACCOUNT,
OOBE,
}
@Serializable
enum class OobeFeature {
@SerialName("voice_input")
VOICE_INPUT,
@SerialName("clipboard_translate")
CLIPBOARD_TRANSLATE,
@SerialName("clipboard_reply")
CLIPBOARD_REPLY,
@SerialName("ask_ai")
ASK_AI,
}
@Serializable
enum class UsageMeter {
@SerialName("llm_token")
@@ -50,10 +70,14 @@ data class GatewayPrincipal(
// Callers must grant capabilities explicitly. An identity with omitted
// scopes is intentionally unable to invoke a managed provider.
val scopes: Set<GatewayCapability> = emptySet(),
val subjectType: GatewaySubjectType = GatewaySubjectType.ACCOUNT,
) {
// Kept as a compatibility name for the existing account-scoped persistence.
val accountId: String
get() = userId
val isOobe: Boolean
get() = subjectType == GatewaySubjectType.OOBE
}
typealias GatewaySubject = GatewayPrincipal
@@ -68,6 +92,7 @@ data class TextGatewayRequest(
val requestSource: GatewayRequestSource? = null,
val taskKind: GatewayTaskKind? = null,
val requestPurpose: GatewayRequestPurpose? = null,
val oobeFeature: OobeFeature? = null,
)
@Serializable
@@ -176,6 +201,7 @@ data class TextProviderRequest(
val stream: Boolean,
override val requestSource: GatewayRequestSource? = null,
override val requestPurpose: GatewayRequestPurpose? = null,
val oobeFeature: OobeFeature? = null,
) : ProviderRequest
data class AsrProviderRequest(
@@ -31,6 +31,8 @@ import com.osglab.account.features.gateway.services.GatewayRefreshTokenInvalidEx
import com.osglab.account.features.gateway.services.GatewayRefreshTokenReuseException
import com.osglab.account.features.gateway.services.GatewayService
import com.osglab.account.features.gateway.services.GatewayTaskPolicyResolver
import com.osglab.account.features.oobe.OobeFeatureAlreadyUsedException
import com.osglab.account.features.oobe.OobeRequestAlreadyClaimedException
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
@@ -150,7 +152,15 @@ fun Route.configureGatewayRoutes(
get("/catalog") {
val requestId = call.gatewayRequestId()
call.requireSubject(gatewayIdentity, requestId) ?: return@get
val subject = call.requireSubject(gatewayIdentity, requestId) ?: return@get
if (subject.isOobe) {
return@get call.respondGatewayError(
HttpStatusCode.Forbidden,
"oobe_request_required",
"OOBE tokens are limited to OOBE LLM requests",
requestId,
)
}
call.respond(GatewayCatalogResponse(service.catalog()))
}
@@ -276,6 +286,7 @@ fun Route.configureGatewayRoutes(
stream = body.stream,
requestSource = body.requestSource,
requestPurpose = body.requestPurpose,
oobeFeature = body.oobeFeature,
)
if (body.stream) {
@@ -444,6 +455,20 @@ private suspend fun ApplicationCall.respondGatewayFailure(
requestId,
)
is OobeFeatureAlreadyUsedException -> respondGatewayError(
HttpStatusCode.Conflict,
"oobe_feature_already_used",
"This OOBE feature has already been used successfully",
requestId,
)
is OobeRequestAlreadyClaimedException -> respondGatewayError(
HttpStatusCode.Conflict,
"oobe_request_replayed",
"This OOBE request ID has already been used",
requestId,
)
is GatewayBodyTooLargeException -> respondGatewayError(
HttpStatusCode.PayloadTooLarge,
"request_too_large",
@@ -226,6 +226,7 @@ class GatewayGrantService(
class GatewayBearerIdentity(
private val grants: GatewayGrantService,
private val authenticateOobe: suspend (String) -> GatewayPrincipal? = { null },
) : GatewayAccessTokenPort {
override suspend fun resolve(call: ApplicationCall): GatewayPrincipal? {
val token = call.request.headers[HttpHeaders.Authorization]
@@ -234,7 +235,7 @@ class GatewayBearerIdentity(
?.trim()
?.takeIf { it.isNotEmpty() && it.length <= MAX_ACCESS_TOKEN_CHARS }
?: return null
return grants.authenticate(token)
return grants.authenticate(token) ?: authenticateOobe(token)
}
private companion object {
@@ -21,10 +21,16 @@ 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 com.osglab.account.features.oobe.OobeContract
import com.osglab.account.features.oobe.OobeFeatureAlreadyUsedException
import com.osglab.account.features.oobe.OobeProviderRequest
import com.osglab.account.features.oobe.OobeRepository
import com.osglab.account.features.oobe.OobeRequestClaim
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.time.Clock
import kotlin.time.TimeSource
class GatewayService(
@@ -33,9 +39,11 @@ class GatewayService(
private val grants: GatewayGrantPort,
private val usageRecords: GatewayUsagePort,
private val complimentaryRequests: ComplimentaryRequestPort = NoComplimentaryRequests,
private val oobeRequests: OobeRepository? = null,
private val usageEstimator: GatewayUsageEstimator = ConservativeGatewayUsageEstimator,
private val llmProviderTimeoutMillis: Long = 120_000L,
private val asrProviderTimeoutMillis: Long = 360_000L,
private val clock: Clock = Clock.systemUTC(),
) {
init {
require(llmProviderTimeoutMillis > 0)
@@ -59,14 +67,39 @@ class GatewayService(
if (request.capability !in subject.scopes) {
throw GatewayAccessDeniedException(request.capability)
}
if (!grants.isAllowed(subject.accountId, request.capability)) {
if (!subject.isOobe && request is TextProviderRequest && request.oobeFeature != null) {
throw GatewayAccessDeniedException(request.capability)
}
if (subject.isOobe) {
validateAnonymousOobeRequest(request)
} else 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 complimentaryClaim = request.requestPurpose?.let { purpose ->
val oobeClaim = if (subject.isOobe) {
val textRequest = request as TextProviderRequest
val feature = requireNotNull(textRequest.oobeFeature)
val now = clock.instant()
requireNotNull(oobeRequests).claim(
OobeProviderRequest(
subjectId = subject.userId,
grantId = requireNotNull(subject.grantId),
feature = feature,
requestId = request.requestId,
providerId = provider.descriptor.id,
capability = request.capability,
purpose = GatewayRequestPurpose.OOBE,
),
expiresAt = now.plus(OOBE_CLAIM_TTL),
now = now,
) ?: throw OobeFeatureAlreadyUsedException(feature)
} else {
null
}
val complimentaryClaim = request.requestPurpose?.takeUnless { subject.isOobe }?.let { purpose ->
validateComplimentaryRequest(request, purpose)
complimentaryRequests.claim(
accountId = subject.accountId,
@@ -75,7 +108,7 @@ class GatewayService(
requestId = request.requestId,
) ?: throw ComplimentaryRequestUnavailableException(purpose)
}
val reservation = if (complimentaryClaim == null) {
val reservation = if (complimentaryClaim == null && oobeClaim == null) {
credits.reserve(
accountId = subject.accountId,
estimate = estimate,
@@ -85,38 +118,45 @@ class GatewayService(
null
}
try {
usageRecords.claim(
ProviderRequestMetadata(
requestId = request.requestId,
accountId = subject.accountId,
reservationId = reservation?.id,
providerId = provider.descriptor.id,
capability = request.capability,
requestSource = request.requestSource,
requestPurpose = request.requestPurpose,
),
)
if (!subject.isOobe) {
usageRecords.claim(
ProviderRequestMetadata(
requestId = request.requestId,
accountId = subject.accountId,
reservationId = reservation?.id,
providerId = provider.descriptor.id,
capability = request.capability,
requestSource = request.requestSource,
requestPurpose = request.requestPurpose,
),
)
}
} catch (replay: GatewayRequestAlreadyClaimedException) {
// The existing claim owns the reservation. Releasing it here would
// refund an in-flight or completed paid request. Complimentary
// claims are newly acquired above and must not remain stranded.
if (complimentaryClaim != null) {
releaseAfterFailure(null, complimentaryClaim, replay)
releaseAfterFailure(null, complimentaryClaim, oobeClaim, replay)
}
throw replay
} catch (failure: Throwable) {
releaseAfterFailure(reservation, complimentaryClaim, failure)
releaseAfterFailure(reservation, complimentaryClaim, oobeClaim, failure)
throw failure
}
try {
usageRecords.markStarted(subject.accountId, request.requestId)
if (oobeClaim != null) {
requireNotNull(oobeRequests).markStarted(oobeClaim)
} else {
usageRecords.markStarted(subject.accountId, request.requestId)
}
} catch (failure: Throwable) {
releaseAndRecord(
subject.accountId,
request.requestId,
reservation,
complimentaryClaim,
oobeClaim,
failure,
)
throw failure
@@ -129,6 +169,7 @@ class GatewayService(
estimate,
reservation,
complimentaryClaim,
oobeClaim,
)
}
@@ -172,6 +213,10 @@ class GatewayService(
// Once upstream has completed, cancellation must not interrupt durable
// metering. The reservation remains frozen if any settlement step fails.
withContext(NonCancellable) {
if (prepared.oobeClaim != null) {
settleOobe(prepared, usage)
return@withContext
}
if (prepared.complimentaryClaim != null) {
settleComplimentary(prepared, usage)
return@withContext
@@ -214,6 +259,7 @@ class GatewayService(
prepared.request.requestId,
prepared.reservation,
prepared.complimentaryClaim,
prepared.oobeClaim,
failure,
)
}
@@ -224,11 +270,15 @@ class GatewayService(
failure: Throwable,
) {
runCatching {
usageRecords.markManualReview(
prepared.subject.accountId,
prepared.request.requestId,
errorCode,
)
if (prepared.oobeClaim != null) {
requireNotNull(oobeRequests).markManualReview(prepared.oobeClaim, errorCode)
} else {
usageRecords.markManualReview(
prepared.subject.accountId,
prepared.request.requestId,
errorCode,
)
}
}.onFailure(failure::addSuppressed)
}
@@ -237,14 +287,20 @@ class GatewayService(
requestId: String,
reservation: CreditReservation?,
complimentaryClaim: ComplimentaryRequestClaim?,
oobeClaim: OobeRequestClaim?,
failure: Throwable,
): Unit = withContext(NonCancellable) {
val released = if (complimentaryClaim != null) {
runCatching { complimentaryRequests.release(complimentaryClaim) }
} else {
runCatching { credits.release(requireNotNull(reservation).id) }
val released = when {
oobeClaim != null -> runCatching {
requireNotNull(oobeRequests).release(
oobeClaim,
failure::class.simpleName ?: "provider_error",
)
}
complimentaryClaim != null -> runCatching { complimentaryRequests.release(complimentaryClaim) }
else -> runCatching { credits.release(requireNotNull(reservation).id) }
}
if (released.isSuccess) {
if (released.isSuccess && oobeClaim == null) {
runCatching {
usageRecords.markReleased(
accountId,
@@ -252,11 +308,17 @@ class GatewayService(
failure::class.simpleName ?: "provider_error",
)
}.onFailure(failure::addSuppressed)
} else {
} else if (released.isFailure) {
released.exceptionOrNull()?.let(failure::addSuppressed)
runCatching {
usageRecords.markManualReview(accountId, requestId, "release_pending")
}.onFailure(failure::addSuppressed)
if (oobeClaim != null) {
runCatching {
requireNotNull(oobeRequests).markManualReview(oobeClaim, "release_pending")
}.onFailure(failure::addSuppressed)
} else {
runCatching {
usageRecords.markManualReview(accountId, requestId, "release_pending")
}.onFailure(failure::addSuppressed)
}
}
}
@@ -284,6 +346,19 @@ class GatewayService(
}
}
private suspend fun settleOobe(
prepared: PreparedGatewayRequest,
usage: ProviderUsage,
) {
val claim = requireNotNull(prepared.oobeClaim)
runCatching { requireNotNull(oobeRequests).consume(claim, usage) }
.onFailure {
runCatching {
requireNotNull(oobeRequests).markManualReview(claim, "oobe_consume_pending")
}
}
}
private fun validateUsage(usage: ProviderUsage, estimate: ProviderUsageEstimate) {
if (usage.meter != estimate.meter) {
throw GatewayUsagePolicyException("Provider usage meter differs from the reservation")
@@ -352,15 +427,33 @@ class GatewayService(
}
}
private fun validateAnonymousOobeRequest(request: ProviderRequest) {
require(request is TextProviderRequest) { "OOBE tokens support only LLM requests" }
require(request.requestPurpose == GatewayRequestPurpose.OOBE) {
"OOBE tokens require requestPurpose=oobe"
}
val feature = requireNotNull(request.oobeFeature) { "OOBE tokens require oobeFeature" }
val policy = OobeContract.policy(feature)
require(request.capability == policy.capability && request.executionPolicy.taskKind == policy.taskKind) {
"oobeFeature does not match capability and taskKind"
}
}
private suspend fun releaseAfterFailure(
reservation: CreditReservation?,
complimentaryClaim: ComplimentaryRequestClaim?,
oobeClaim: OobeRequestClaim?,
failure: Throwable,
): Unit = withContext(NonCancellable) {
val released = if (complimentaryClaim != null) {
runCatching { complimentaryRequests.release(complimentaryClaim) }
} else {
runCatching { credits.release(requireNotNull(reservation).id) }
val released = when {
oobeClaim != null -> runCatching {
requireNotNull(oobeRequests).release(
oobeClaim,
failure::class.simpleName ?: "provider_error",
)
}
complimentaryClaim != null -> runCatching { complimentaryRequests.release(complimentaryClaim) }
else -> runCatching { credits.release(requireNotNull(reservation).id) }
}
released
.onFailure(failure::addSuppressed)
@@ -368,6 +461,7 @@ class GatewayService(
private companion object {
val PROVIDER_REQUEST_ID = Regex("[A-Za-z0-9._:-]{8,64}")
val OOBE_CLAIM_TTL: java.time.Duration = java.time.Duration.ofMinutes(15)
}
}
@@ -378,6 +472,7 @@ data class PreparedGatewayRequest(
val estimate: ProviderUsageEstimate,
val reservation: CreditReservation?,
val complimentaryClaim: ComplimentaryRequestClaim?,
val oobeClaim: OobeRequestClaim?,
)
class GatewayReconciliationService(
@@ -8,22 +8,35 @@ import io.ktor.server.routing.get
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
fun Route.healthRoutes(databaseFactory: DatabaseFactory) {
fun Route.healthRoutes(
databaseFactory: DatabaseFactory,
buildSha: String = System.getenv("APP_BUILD_SHA")
?.takeIf(BUILD_SHA::matches)
?: "unknown",
) {
route("/health") {
get("/live") {
call.respond(HealthResponse(status = "UP"))
call.respond(HealthResponse(status = "UP", buildSha = buildSha))
}
get("/ready") {
val databaseReady = databaseFactory.isReady()
if (databaseReady) {
call.respond(HealthResponse(status = "UP"))
call.respond(HealthResponse(status = "UP", buildSha = buildSha))
} else {
call.respond(HttpStatusCode.ServiceUnavailable, HealthResponse(status = "DOWN"))
call.respond(
HttpStatusCode.ServiceUnavailable,
HealthResponse(status = "DOWN", buildSha = buildSha),
)
}
}
}
}
@Serializable
private data class HealthResponse(val status: String)
private data class HealthResponse(
val status: String,
val buildSha: String,
)
private val BUILD_SHA = Regex("[0-9a-f]{40}")
@@ -170,9 +170,18 @@ class LibraryAppAttestCrypto(
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
private val allowedAaguids = buildList {
add(
when (config.appleEnvironment) {
AppleServiceEnvironment.DEVELOPMENT -> DEVELOPMENT_AAGUID
AppleServiceEnvironment.PRODUCTION -> PRODUCTION_AAGUID
},
)
if (config.allowDevelopmentAppAttest &&
config.appleEnvironment == AppleServiceEnvironment.PRODUCTION
) {
add(DEVELOPMENT_AAGUID)
}
}
override suspend fun validateAttestation(
@@ -197,7 +206,7 @@ class LibraryAppAttestCrypto(
if (authenticatorData.signCount != 0L) {
throw AppAttestRejectedException("App Attest attestation counter must start at zero")
}
if (!MessageDigest.isEqual(authenticatorData.aaguid, expectedAaguid)) {
if (allowedAaguids.none { MessageDigest.isEqual(authenticatorData.aaguid, it) }) {
throw AppAttestRejectedException("App Attest AAGUID does not match the configured environment")
}
val decodedKeyId = decodeKeyId(keyId)
@@ -0,0 +1,388 @@
package com.osglab.account.features.oobe
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.gateway.models.ProviderUsage
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.lessEq
import org.jetbrains.exposed.v1.core.or
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.time.Clock
private object OobeSubjectsTable : Table("oobe_subjects") {
val id = varchar("id", 36)
val keyId = varchar("key_id", 128)
val installationHash = char("installation_hash", 64)
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
private object OobeGrantsTable : Table("oobe_gateway_grants") {
val id = varchar("id", 36)
val subjectId = varchar("subject_id", 36)
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 OobeRefreshTokensTable : Table("oobe_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)
}
private object OobeClaimsTable : Table("oobe_gateway_claims") {
val subjectId = varchar("subject_id", 36)
val feature = varchar("feature", 32)
val requestId = varchar("request_id", 64)
val status = varchar("status", 16)
val expiresAt = timestamp("expires_at")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(subjectId, feature)
}
private object OobeProviderRequestsTable : Table("oobe_provider_requests") {
val subjectId = varchar("subject_id", 36)
val requestId = varchar("request_id", 64)
val grantId = varchar("grant_id", 36)
val feature = varchar("feature", 32)
val providerId = varchar("provider_id", 64)
val capability = varchar("capability", 32)
val requestPurpose = varchar("request_purpose", 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(subjectId, requestId)
}
class ExposedOobeRepository(
private val databaseFactory: DatabaseFactory,
private val clock: Clock = Clock.systemUTC(),
) : OobeRepository {
override suspend fun findOrCreateSubject(
keyId: String,
installationHash: String,
subjectId: String,
now: Instant,
): OobeSubject = databaseFactory.query {
OobeSubjectsTable.insertIgnore {
it[id] = subjectId
it[OobeSubjectsTable.keyId] = keyId
it[OobeSubjectsTable.installationHash] = installationHash
it[createdAt] = now
it[updatedAt] = now
}
OobeSubjectsTable.selectAll()
.where { OobeSubjectsTable.keyId eq keyId }
.single()
.also {
require(it[OobeSubjectsTable.installationHash] == installationHash) {
"App Attest key is already bound to another installation"
}
}
.let {
OobeSubject(
id = it[OobeSubjectsTable.id],
keyId = it[OobeSubjectsTable.keyId],
installationHash = it[OobeSubjectsTable.installationHash],
)
}
}
override suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh =
databaseFactory.query {
OobeGrantsTable.insert {
it[id] = grant.grant.id
it[subjectId] = grant.grant.subjectId
it[expiresAt] = grant.grant.expiresAt
it[createdAt] = now
it[updatedAt] = now
}
OobeRefreshTokensTable.insert {
it[id] = grant.refreshTokenId
it[grantId] = grant.grant.id
it[familyId] = grant.refreshFamilyId
it[tokenHash] = grant.refreshTokenHash
it[expiresAt] = minOf(grant.refreshExpiresAt, grant.grant.expiresAt)
it[createdAt] = now
}
StoredOobeRefresh(
grant = grant.grant,
tokenId = grant.refreshTokenId,
familyId = grant.refreshFamilyId,
expiresAt = minOf(grant.refreshExpiresAt, grant.grant.expiresAt),
)
}
override suspend fun rotateRefresh(
currentTokenHash: String,
rotationIdempotencyKey: String,
newTokenId: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): OobeRefreshRotationResult = databaseFactory.query {
val current = OobeRefreshTokensTable.selectAll()
.where { OobeRefreshTokensTable.tokenHash eq currentTokenHash }
.forUpdate()
.singleOrNull()
?: return@query OobeRefreshRotationResult.Invalid
val grant = OobeGrantsTable.selectAll()
.where { OobeGrantsTable.id eq current[OobeRefreshTokensTable.grantId] }
.forUpdate()
.single()
current[OobeRefreshTokensTable.replacedById]?.let { replacementId ->
if (current[OobeRefreshTokensTable.rotationIdempotencyKey] == rotationIdempotencyKey) {
val replacement = OobeRefreshTokensTable.selectAll()
.where { OobeRefreshTokensTable.id eq replacementId }
.single()
return@query OobeRefreshRotationResult.Rotated(
replacement.toStoredRefresh(grant.toOobeGrant()),
)
}
OobeRefreshTokensTable.update({
OobeRefreshTokensTable.familyId eq current[OobeRefreshTokensTable.familyId]
}) {
it[revokedAt] = now
}
OobeRefreshTokensTable.update({ OobeRefreshTokensTable.id eq current[OobeRefreshTokensTable.id] }) {
it[reuseDetectedAt] = now
}
OobeGrantsTable.update({ OobeGrantsTable.id eq grant[OobeGrantsTable.id] }) {
it[revokedAt] = now
it[updatedAt] = now
}
return@query OobeRefreshRotationResult.ReuseDetected
}
if (current[OobeRefreshTokensTable.revokedAt] != null ||
!current[OobeRefreshTokensTable.expiresAt].isAfter(now) ||
grant[OobeGrantsTable.revokedAt] != null ||
!grant[OobeGrantsTable.expiresAt].isAfter(now)
) {
return@query OobeRefreshRotationResult.Invalid
}
val expiresAt = minOf(newExpiresAt, grant[OobeGrantsTable.expiresAt])
OobeRefreshTokensTable.insert {
it[id] = newTokenId
it[grantId] = current[OobeRefreshTokensTable.grantId]
it[familyId] = current[OobeRefreshTokensTable.familyId]
it[tokenHash] = newTokenHash
it[OobeRefreshTokensTable.expiresAt] = expiresAt
it[createdAt] = now
}
OobeRefreshTokensTable.update({ OobeRefreshTokensTable.id eq current[OobeRefreshTokensTable.id] }) {
it[replacedById] = newTokenId
it[OobeRefreshTokensTable.rotationIdempotencyKey] = rotationIdempotencyKey
it[revokedAt] = now
}
OobeRefreshRotationResult.Rotated(
StoredOobeRefresh(
grant = grant.toOobeGrant(),
tokenId = newTokenId,
familyId = current[OobeRefreshTokensTable.familyId],
expiresAt = expiresAt,
),
)
}
override suspend fun findActiveGrant(
grantId: String,
subjectId: String,
now: Instant,
): OobeGrant? = databaseFactory.query {
OobeGrantsTable.selectAll()
.where {
(OobeGrantsTable.id eq grantId) and
(OobeGrantsTable.subjectId eq subjectId) and
OobeGrantsTable.revokedAt.isNull() and
(OobeGrantsTable.expiresAt greater now)
}
.singleOrNull()
?.toOobeGrant()
}
override suspend fun claim(
request: OobeProviderRequest,
expiresAt: Instant,
now: Instant,
): OobeRequestClaim? = databaseFactory.query {
val key = claimKey(request.subjectId, request.feature.name)
val reclaimed = OobeClaimsTable.update({
key and
(OobeClaimsTable.status eq CLAIMED) and
(OobeClaimsTable.expiresAt lessEq now)
}) {
it[requestId] = request.requestId
it[OobeClaimsTable.expiresAt] = expiresAt
it[updatedAt] = now
} == 1
val inserted = !reclaimed && OobeClaimsTable.insertIgnore {
it[subjectId] = request.subjectId
it[feature] = request.feature.name
it[requestId] = request.requestId
it[status] = CLAIMED
it[OobeClaimsTable.expiresAt] = expiresAt
it[createdAt] = now
it[updatedAt] = now
}.insertedCount == 1
if (!reclaimed && !inserted) return@query null
val auditInserted = OobeProviderRequestsTable.insertIgnore {
it[subjectId] = request.subjectId
it[requestId] = request.requestId
it[grantId] = request.grantId
it[feature] = request.feature.name
it[providerId] = request.providerId
it[capability] = request.capability.name
it[requestPurpose] = request.purpose.name
it[status] = OobeProviderRequestState.CLAIMED.name
it[createdAt] = now
}.insertedCount == 1
if (!auditInserted) throw OobeRequestAlreadyClaimedException()
OobeRequestClaim(request.subjectId, request.feature, request.requestId)
}
override suspend fun markStarted(claim: OobeRequestClaim) {
transition(claim, OobeProviderRequestState.CLAIMED, OobeProviderRequestState.STARTED)
}
override suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage) {
databaseFactory.query {
val now = clock.instant()
val claimChanged = OobeClaimsTable.update({
claimKey(claim.subjectId, claim.feature.name) and
(OobeClaimsTable.requestId eq claim.requestId) and
(OobeClaimsTable.status eq CLAIMED)
}) {
it[status] = CONSUMED
it[updatedAt] = now
}
check(claimChanged == 1) { "OOBE feature claim cannot be consumed" }
val auditChanged = OobeProviderRequestsTable.update({
requestKey(claim) and
(OobeProviderRequestsTable.status eq OobeProviderRequestState.STARTED.name)
}) {
it[status] = OobeProviderRequestState.SUCCEEDED.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[completedAt] = now
}
check(auditChanged == 1) { "OOBE provider request cannot be completed" }
}
}
override suspend fun release(claim: OobeRequestClaim, errorCode: String) {
databaseFactory.query {
OobeClaimsTable.deleteWhere {
claimKey(claim.subjectId, claim.feature.name) and
(OobeClaimsTable.requestId eq claim.requestId) and
(OobeClaimsTable.status eq CLAIMED)
}
val changed = OobeProviderRequestsTable.update({
requestKey(claim) and
(
(OobeProviderRequestsTable.status eq OobeProviderRequestState.CLAIMED.name) or
(OobeProviderRequestsTable.status eq OobeProviderRequestState.STARTED.name)
)
}) {
it[status] = OobeProviderRequestState.RELEASED.name
it[OobeProviderRequestsTable.errorCode] = errorCode.take(96)
it[completedAt] = clock.instant()
}
check(changed == 1) { "OOBE provider request cannot be released" }
}
}
override suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String) {
databaseFactory.query {
// Fail closed: an uncertain provider outcome must never become
// reclaimable after the temporary claim TTL.
OobeClaimsTable.update({
claimKey(claim.subjectId, claim.feature.name) and
(OobeClaimsTable.requestId eq claim.requestId) and
(OobeClaimsTable.status eq CLAIMED)
}) {
it[status] = CONSUMED
it[updatedAt] = clock.instant()
}
OobeProviderRequestsTable.update({ requestKey(claim) }) {
it[status] = OobeProviderRequestState.MANUAL_REVIEW.name
it[OobeProviderRequestsTable.errorCode] = errorCode.take(96)
}
}
}
private suspend fun transition(
claim: OobeRequestClaim,
from: OobeProviderRequestState,
to: OobeProviderRequestState,
) {
databaseFactory.query {
val changed = OobeProviderRequestsTable.update({
requestKey(claim) and (OobeProviderRequestsTable.status eq from.name)
}) {
it[status] = to.name
}
check(changed == 1) { "OOBE provider request cannot transition from $from to $to" }
}
}
}
private fun org.jetbrains.exposed.v1.core.ResultRow.toOobeGrant() = OobeGrant(
id = this[OobeGrantsTable.id],
subjectId = this[OobeGrantsTable.subjectId],
expiresAt = this[OobeGrantsTable.expiresAt],
revokedAt = this[OobeGrantsTable.revokedAt],
)
private fun org.jetbrains.exposed.v1.core.ResultRow.toStoredRefresh(grant: OobeGrant) =
StoredOobeRefresh(
grant = grant,
tokenId = this[OobeRefreshTokensTable.id],
familyId = this[OobeRefreshTokensTable.familyId],
expiresAt = this[OobeRefreshTokensTable.expiresAt],
)
private fun claimKey(subjectId: String, feature: String) =
(OobeClaimsTable.subjectId eq subjectId) and (OobeClaimsTable.feature eq feature)
private fun requestKey(claim: OobeRequestClaim) =
(OobeProviderRequestsTable.subjectId eq claim.subjectId) and
(OobeProviderRequestsTable.requestId eq claim.requestId)
private const val CLAIMED = "CLAIMED"
private const val CONSUMED = "CONSUMED"
@@ -0,0 +1,240 @@
package com.osglab.account.features.oobe
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.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.GatewaySubjectType
import com.osglab.account.features.integrity.AppAttestService
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.util.Base64
import java.util.Date
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
class OobeGrantService(
private val repository: OobeRepository,
private val appAttest: AppAttestService,
private val settings: OobeTokenSettings,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun create(request: CreateOobeGrantRequest): OobeGrantTokens {
val installationId = canonicalInstallationId(request.installationId)
val challenge = decodeChallenge(request.challenge)
val canonicalPayload = OobeContract.canonicalAssertionPayload(
challenge = challenge,
keyId = request.keyId,
installationId = installationId,
)
appAttest.verifyBoundAssertion(
challengeId = request.challengeId,
challenge = challenge,
keyId = request.keyId,
assertionObject = request.assertion,
expectedClientDataHash = sha256(canonicalPayload),
)
val now = clock.instant()
val subject = repository.findOrCreateSubject(
keyId = request.keyId,
installationHash = sha256Hex(installationId.toByteArray(StandardCharsets.UTF_8)),
subjectId = UUID.randomUUID().toString(),
now = now,
)
val grantId = UUID.randomUUID().toString()
val tokenId = UUID.randomUUID().toString()
val familyId = UUID.randomUUID().toString()
val grantExpiresAt = now.plus(GRANT_LIFETIME)
val refreshToken = refreshToken(grantId, familyId, tokenId)
val stored = repository.createGrant(
NewOobeGrant(
grant = OobeGrant(grantId, subject.id, grantExpiresAt),
refreshTokenId = tokenId,
refreshFamilyId = familyId,
refreshTokenHash = tokenHash(refreshToken),
refreshExpiresAt = grantExpiresAt,
),
now,
)
return issue(stored)
}
suspend fun refresh(refreshToken: String, idempotencyKey: String): OobeGrantTokens {
require(IDEMPOTENCY_KEY.matches(idempotencyKey)) { "Idempotency key is invalid" }
if (refreshToken.length !in 32..MAX_REFRESH_TOKEN_CHARS) {
throw OobeRefreshTokenInvalidException()
}
parseRefreshToken(refreshToken)
val now = clock.instant()
val tokenId = UUID.randomUUID().toString()
val result = repository.rotateRefresh(
currentTokenHash = tokenHash(refreshToken),
rotationIdempotencyKey = idempotencyKey,
newTokenId = tokenId,
newTokenHash = tokenHash(replaceTokenId(refreshToken, tokenId)),
newExpiresAt = now.plus(GRANT_LIFETIME),
now = now,
)
return when (result) {
is OobeRefreshRotationResult.Rotated -> issue(result.refresh)
OobeRefreshRotationResult.Invalid -> throw OobeRefreshTokenInvalidException()
OobeRefreshRotationResult.ReuseDetected -> throw OobeRefreshTokenReuseException()
}
}
suspend fun authenticate(serialized: String): GatewayPrincipal? {
val principal = verifyAccessToken(serialized) ?: return null
return repository.findActiveGrant(
grantId = requireNotNull(principal.grantId),
subjectId = principal.userId,
now = clock.instant(),
)?.let {
principal
}
}
private fun issue(refresh: StoredOobeRefresh): OobeGrantTokens {
val now = clock.instant()
val accessExpiresAt = minOf(now.plus(ACCESS_LIFETIME), refresh.grant.expiresAt)
require(accessExpiresAt.isAfter(now)) { "OOBE gateway grant has expired" }
require(refresh.expiresAt.isAfter(now)) { "OOBE refresh token has expired" }
val claims = JWTClaimsSet.Builder()
.issuer(settings.issuer)
.audience(settings.audience)
.subject("$SUBJECT_PREFIX${refresh.grant.subjectId}")
.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, OobeContract.scopes.map { it.name.lowercase() }.sorted())
.claim(CLAIM_FEATURES, OobeContract.features.map { it.name.lowercase() }.sorted())
.build()
val jwt = SignedJWT(JWSHeader(JWSAlgorithm.HS256), claims)
jwt.sign(MACSigner(settings.accessTokenHmacSecret))
return OobeGrantTokens(
grantId = refresh.grant.id,
scopes = OobeContract.scopes,
features = OobeContract.features,
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) == true)
require(claims.notBeforeTime?.toInstant()?.isBefore(now.plusSeconds(CLOCK_SKEW_SECONDS)) != false)
require(claims.issueTime?.toInstant()?.isAfter(now.plusSeconds(CLOCK_SKEW_SECONDS)) != true)
require(claims.getStringListClaim(CLAIM_SCOPES).map(String::uppercase)
.map(GatewayCapability::valueOf).toSet() == OobeContract.scopes)
require(claims.getStringListClaim(CLAIM_FEATURES).map(String::uppercase).toSet() ==
OobeContract.features.map { it.name }.toSet())
val subject = claims.subject
require(subject.startsWith(SUBJECT_PREFIX))
val subjectId = UUID.fromString(subject.removePrefix(SUBJECT_PREFIX)).toString()
GatewayPrincipal(
userId = subjectId,
grantId = UUID.fromString(claims.getStringClaim(CLAIM_GRANT_ID)).toString(),
scopes = OobeContract.scopes,
subjectType = GatewaySubjectType.OOBE,
)
}.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("$REFRESH_CONTEXT:$publicPart".toByteArray(StandardCharsets.US_ASCII)))
return "$REFRESH_PREFIX$publicPart.$secret"
}
private fun parseRefreshToken(value: String) {
if (!value.startsWith(REFRESH_PREFIX)) throw OobeRefreshTokenInvalidException()
val parts = value.removePrefix(REFRESH_PREFIX).split('.')
if (parts.size != 4) throw OobeRefreshTokenInvalidException()
val grantId = canonicalUuid(parts[0])
val familyId = canonicalUuid(parts[1])
val tokenId = canonicalUuid(parts[2])
val expected = refreshToken(grantId, familyId, tokenId)
if (!MessageDigest.isEqual(
expected.toByteArray(StandardCharsets.US_ASCII),
value.toByteArray(StandardCharsets.US_ASCII),
)
) {
throw OobeRefreshTokenInvalidException()
}
}
private fun replaceTokenId(value: String, newTokenId: String): String {
val parts = value.removePrefix(REFRESH_PREFIX).split('.')
return refreshToken(parts[0], parts[1], newTokenId)
}
private fun canonicalInstallationId(value: String): String =
runCatching { UUID.fromString(value).toString() }
.getOrElse { throw IllegalArgumentException("installationId must be a UUID") }
private fun canonicalUuid(value: String): String =
runCatching { UUID.fromString(value).toString() }
.getOrElse { throw OobeRefreshTokenInvalidException() }
private fun decodeChallenge(value: String): ByteArray =
runCatching { Base64.getUrlDecoder().decode(value) }
.getOrElse { throw IllegalArgumentException("challenge must be Base64URL") }
.also { require(it.size == CHALLENGE_BYTES) { "challenge size is invalid" } }
private fun tokenHash(value: String): String = sha256Hex(value.toByteArray(StandardCharsets.US_ASCII))
private companion object {
val GRANT_LIFETIME: Duration = Duration.ofMinutes(30)
val ACCESS_LIFETIME: Duration = Duration.ofMinutes(5)
const val HMAC_ALGORITHM = "HmacSHA256"
const val REFRESH_CONTEXT = "oobe-refresh"
const val CLAIM_TYPE = "typ"
const val CLAIM_GRANT_ID = "gid"
const val CLAIM_SCOPES = "scp"
const val CLAIM_FEATURES = "features"
const val ACCESS_TOKEN_TYPE = "oobe_gateway_access"
const val SUBJECT_PREFIX = "oobe:"
const val REFRESH_PREFIX = "oobert_"
const val CLOCK_SKEW_SECONDS = 30L
const val CHALLENGE_BYTES = 32
const val MAX_REFRESH_TOKEN_CHARS = 512
val IDEMPOTENCY_KEY = Regex("[A-Za-z0-9._:-]{8,128}")
}
}
class OobeRefreshTokenInvalidException : RuntimeException("OOBE refresh token is invalid")
class OobeRefreshTokenReuseException : RuntimeException("OOBE refresh token reuse was detected")
data class OobeTokenSettings(
val issuer: String,
val audience: String,
val accessTokenHmacSecret: ByteArray,
val refreshTokenHmacSecret: ByteArray,
)
private fun sha256(value: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(value)
private fun sha256Hex(value: ByteArray): String =
sha256(value).joinToString("") { "%02x".format(it.toInt() and 0xff) }
@@ -0,0 +1,128 @@
package com.osglab.account.features.oobe
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
import com.osglab.account.features.gateway.models.GatewayTaskKind
import com.osglab.account.features.gateway.models.OobeFeature
import kotlinx.serialization.Serializable
import java.time.Instant
import java.util.Base64
@Serializable
data class CreateOobeGrantRequest(
val challengeId: String,
val challenge: String,
val keyId: String,
val installationId: String,
val assertion: String,
)
@Serializable
data class RefreshOobeGrantRequest(val refreshToken: String)
@Serializable
data class OobeGrantTokens(
val grantId: String,
val scopes: Set<GatewayCapability>,
val features: Set<OobeFeature>,
val accessToken: String,
val accessExpiresAt: String,
val refreshToken: String,
val refreshExpiresAt: String,
)
data class OobeFeaturePolicy(
val capability: GatewayCapability,
val taskKind: GatewayTaskKind,
)
object OobeContract {
val scopes: Set<GatewayCapability> = setOf(GatewayCapability.POLISH, GatewayCapability.AI)
val features: Set<OobeFeature> = OobeFeature.entries.toSet()
fun policy(feature: OobeFeature): OobeFeaturePolicy = when (feature) {
OobeFeature.VOICE_INPUT ->
OobeFeaturePolicy(GatewayCapability.POLISH, GatewayTaskKind.DICTATION_POLISH)
OobeFeature.CLIPBOARD_TRANSLATE,
OobeFeature.CLIPBOARD_REPLY ->
OobeFeaturePolicy(GatewayCapability.AI, GatewayTaskKind.CLIPBOARD_TRANSFORM)
OobeFeature.ASK_AI ->
OobeFeaturePolicy(GatewayCapability.AI, GatewayTaskKind.AI_QUESTION)
}
fun canonicalAssertionPayload(
challenge: ByteArray,
keyId: String,
installationId: String,
): ByteArray = buildString {
appendLine("osg-app-attest-v1")
appendLine("purpose=oobe-gateway-grant")
appendLine("challenge=${BASE64_URL.encodeToString(challenge)}")
appendLine("key_id=$keyId")
appendLine("installation_id=$installationId")
appendLine("scopes=ai,polish")
appendLine("features=ask_ai,clipboard_reply,clipboard_translate,voice_input")
appendLine("grant_ttl_seconds=1800")
appendLine("access_ttl_seconds=300")
}.toByteArray(Charsets.UTF_8)
}
data class OobeSubject(
val id: String,
val keyId: String,
val installationHash: String,
)
data class OobeGrant(
val id: String,
val subjectId: String,
val expiresAt: Instant,
val revokedAt: Instant? = null,
)
data class NewOobeGrant(
val grant: OobeGrant,
val refreshTokenId: String,
val refreshFamilyId: String,
val refreshTokenHash: String,
val refreshExpiresAt: Instant,
)
data class StoredOobeRefresh(
val grant: OobeGrant,
val tokenId: String,
val familyId: String,
val expiresAt: Instant,
)
sealed interface OobeRefreshRotationResult {
data class Rotated(val refresh: StoredOobeRefresh) : OobeRefreshRotationResult
data object Invalid : OobeRefreshRotationResult
data object ReuseDetected : OobeRefreshRotationResult
}
data class OobeRequestClaim(
val subjectId: String,
val feature: OobeFeature,
val requestId: String,
)
data class OobeProviderRequest(
val subjectId: String,
val grantId: String,
val feature: OobeFeature,
val requestId: String,
val providerId: String,
val capability: GatewayCapability,
val purpose: GatewayRequestPurpose,
)
enum class OobeProviderRequestState {
CLAIMED,
STARTED,
SUCCEEDED,
RELEASED,
MANUAL_REVIEW,
}
private val BASE64_URL: Base64.Encoder = Base64.getUrlEncoder().withoutPadding()
@@ -0,0 +1,42 @@
package com.osglab.account.features.oobe
import com.osglab.account.features.gateway.models.ProviderUsage
import java.time.Instant
interface OobeRepository {
suspend fun findOrCreateSubject(
keyId: String,
installationHash: String,
subjectId: String,
now: Instant,
): OobeSubject
suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh
suspend fun rotateRefresh(
currentTokenHash: String,
rotationIdempotencyKey: String,
newTokenId: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): OobeRefreshRotationResult
suspend fun findActiveGrant(grantId: String, subjectId: String, now: Instant): OobeGrant?
suspend fun claim(request: OobeProviderRequest, expiresAt: Instant, now: Instant): OobeRequestClaim?
suspend fun markStarted(claim: OobeRequestClaim)
suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage)
suspend fun release(claim: OobeRequestClaim, errorCode: String)
suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String)
}
class OobeRequestAlreadyClaimedException :
RuntimeException("The OOBE provider request ID has already been used")
class OobeFeatureAlreadyUsedException(val feature: com.osglab.account.features.gateway.models.OobeFeature) :
RuntimeException("The OOBE feature ${feature.name.lowercase()} has already been used")
@@ -0,0 +1,130 @@
package com.osglab.account.features.oobe
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.features.gateway.models.GatewayErrorResponse
import com.osglab.account.features.integrity.AppAttestRejectedException
import com.osglab.account.features.integrity.AppAttestUnavailableException
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receiveChannel
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 java.util.UUID
import kotlinx.serialization.json.Json
import kotlinx.io.readByteArray
import io.ktor.utils.io.readRemaining
fun Route.oobeRoutes(service: OobeGrantService) {
route("/v1/oobe/grants") {
post {
val requestId = call.requestId()
val request = runCatching {
OOBE_JSON.decodeFromString<CreateOobeGrantRequest>(call.receiveOobeBody())
}.getOrElse {
return@post call.respond(
HttpStatusCode.BadRequest,
GatewayErrorResponse("invalid_oobe_grant", "OOBE grant request is invalid", requestId),
)
}
try {
call.respond(HttpStatusCode.Created, service.create(request))
} catch (_: AppAttestRejectedException) {
call.respond(
HttpStatusCode.Unauthorized,
GatewayErrorResponse("app_attest_rejected", "App Attest assertion was rejected", requestId),
)
} catch (_: AppAttestUnavailableException) {
call.respond(
HttpStatusCode.ServiceUnavailable,
GatewayErrorResponse(
"app_attest_unavailable",
"App Attest verification is unavailable",
requestId,
),
)
} catch (_: InvalidRequestException) {
call.respond(
HttpStatusCode.BadRequest,
GatewayErrorResponse(
"invalid_oobe_grant",
"OOBE grant request is invalid",
requestId,
),
)
} catch (failure: IllegalArgumentException) {
call.respond(
HttpStatusCode.BadRequest,
GatewayErrorResponse(
"invalid_oobe_grant",
failure.message ?: "OOBE grant request is invalid",
requestId,
),
)
}
}
post("/refresh") {
val requestId = call.requestId()
val idempotencyKey = call.request.headers["Idempotency-Key"]
?: return@post call.respond(
HttpStatusCode.BadRequest,
GatewayErrorResponse(
"missing_idempotency_key",
"Idempotency-Key is required",
requestId,
),
)
val request = runCatching {
OOBE_JSON.decodeFromString<RefreshOobeGrantRequest>(call.receiveOobeBody())
}.getOrElse {
return@post call.respond(
HttpStatusCode.BadRequest,
GatewayErrorResponse("invalid_oobe_refresh", "OOBE refresh request is invalid", requestId),
)
}
try {
call.respond(service.refresh(request.refreshToken, idempotencyKey))
} catch (_: OobeRefreshTokenInvalidException) {
call.respond(
HttpStatusCode.Unauthorized,
GatewayErrorResponse("invalid_oobe_refresh", "OOBE refresh token is invalid", requestId),
)
} catch (_: OobeRefreshTokenReuseException) {
call.respond(
HttpStatusCode.Unauthorized,
GatewayErrorResponse("oobe_refresh_reuse", "OOBE refresh token reuse was detected", requestId),
)
} catch (failure: IllegalArgumentException) {
call.respond(
HttpStatusCode.BadRequest,
GatewayErrorResponse(
"invalid_oobe_refresh",
failure.message ?: "OOBE refresh request is invalid",
requestId,
),
)
}
}
}
}
private fun io.ktor.server.application.ApplicationCall.requestId(): String =
request.headers["X-Request-ID"]?.takeIf { REQUEST_ID.matches(it) } ?: UUID.randomUUID().toString()
private val REQUEST_ID = Regex("[A-Za-z0-9_-]{8,64}")
private const val MAX_OOBE_BODY_BYTES = 128 * 1024
private val OOBE_JSON = Json {
ignoreUnknownKeys = false
explicitNulls = false
}
private suspend fun io.ktor.server.application.ApplicationCall.receiveOobeBody(): String {
val declared = request.headers["Content-Length"]?.toLongOrNull()
require(declared == null || declared <= MAX_OOBE_BODY_BYTES)
val bytes = receiveChannel()
.readRemaining(MAX_OOBE_BODY_BYTES.toLong() + 1)
.readByteArray()
require(bytes.size <= MAX_OOBE_BODY_BYTES)
return bytes.decodeToString()
}