Files
OSGAccountServer/src/main/kotlin/com/osglab/account/features/oobe/OobeGrantService.kt
T
Rocky 0d236f57fb
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
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.
2026-08-21 22:55:46 +08:00

241 lines
11 KiB
Kotlin

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) }