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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user