Establish secure account and managed AI backend

Provide the production foundation for Apple identity, immutable credits, referrals, integrity checks, managed providers, and hardened Docker deployment.
This commit is contained in:
Rocky
2026-08-16 14:46:23 +08:00
commit 0af35d44f4
124 changed files with 21052 additions and 0 deletions
@@ -0,0 +1,191 @@
package com.osglab.account.features.auth
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.crypto.RSASSAVerifier
import com.nimbusds.jose.jwk.JWKSet
import com.nimbusds.jose.jwk.KeyOperation
import com.nimbusds.jose.jwk.KeyUse
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.math.BigInteger
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
data class AppleIdentity(
val subject: String,
) {
override fun toString(): String = "AppleIdentity(subject=[REDACTED])"
}
interface AppleJwksProvider {
suspend fun rsaKey(keyId: String): RSAKey?
}
class RemoteAppleJwksProvider(
private val httpClient: HttpClient,
private val jwksUrl: String,
private val clock: Clock = Clock.systemUTC(),
private val cacheTtl: Duration = Duration.ofHours(6),
) : AppleJwksProvider {
private val mutex = Mutex()
private var cached: CachedJwks? = null
init {
require(!cacheTtl.isZero && !cacheTtl.isNegative && cacheTtl <= MAX_JWKS_CACHE_TTL) {
"Apple JWKS cache TTL must be between zero and 24 hours"
}
}
override suspend fun rsaKey(keyId: String): RSAKey? = mutex.withLock {
require(keyId.isNotBlank()) { "Apple key ID must not be blank" }
val nowMillis = clock.millis()
val current = cached
if (current != null && current.expiresAtMillis > nowMillis) {
val cachedKey = current.keys.getKeyByKeyId(keyId) as? RSAKey
if (cachedKey != null) return@withLock cachedKey
if (nowMillis - current.fetchedAtMillis < KEY_MISS_REFRESH_INTERVAL.toMillis()) {
return@withLock null
}
}
val response = runCatching { httpClient.get(jwksUrl) }
.getOrElse { throw AppleVerificationUnavailableException("Apple JWKS request failed", it) }
if (!response.status.isSuccess()) {
throw AppleVerificationUnavailableException("Apple JWKS returned HTTP ${response.status.value}")
}
val keys = runCatching { JWKSet.parse(response.bodyAsText()) }
.getOrElse { throw AppleVerificationUnavailableException("Apple JWKS response was invalid", it) }
val keyIds = keys.keys.map { it.keyID }
if (keys.keys.isEmpty() ||
keys.keys.size > MAX_JWK_COUNT ||
keyIds.any { it.isNullOrBlank() } ||
keyIds.distinct().size != keyIds.size
) {
throw AppleVerificationUnavailableException("Apple JWKS response contained invalid keys")
}
cached = CachedJwks(
keys = keys,
fetchedAtMillis = nowMillis,
expiresAtMillis = nowMillis + cacheTtl.toMillis(),
)
keys.getKeyByKeyId(keyId) as? RSAKey
}
private data class CachedJwks(
val keys: JWKSet,
val fetchedAtMillis: Long,
val expiresAtMillis: Long,
)
}
class AppleIdentityTokenVerifier(
private val config: AppleConfig,
private val jwksProvider: AppleJwksProvider,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun verify(identityToken: String, expectedNonce: String): AppleIdentity {
if (identityToken.isBlank() || identityToken.length > MAX_IDENTITY_TOKEN_LENGTH) {
throw AppleTokenInvalidException("Apple identity token has an invalid size")
}
if (expectedNonce.isBlank() || expectedNonce.length > MAX_NONCE_LENGTH) {
throw AppleTokenInvalidException("Expected nonce has an invalid size")
}
val jwt = runCatching { SignedJWT.parse(identityToken) }
.getOrElse { throw AppleTokenInvalidException("Malformed Apple identity token", it) }
if (jwt.header.algorithm != JWSAlgorithm.RS256) {
throw AppleTokenInvalidException("Apple identity token must use RS256")
}
val keyId = jwt.header.keyID?.takeIf(String::isNotBlank)
?: throw AppleTokenInvalidException("Apple identity token is missing kid")
val key = jwksProvider.rsaKey(keyId)
?: throw AppleTokenInvalidException("Apple identity token used an unknown key")
if (!key.isSuitableAppleSigningKey(keyId)) {
throw AppleTokenInvalidException("Apple identity token used an unsuitable key")
}
if (!runCatching { jwt.verify(RSASSAVerifier(key.toRSAPublicKey())) }.getOrDefault(false)) {
throw AppleTokenInvalidException("Apple identity token signature is invalid")
}
val claims = runCatching { jwt.jwtClaimsSet }
.getOrElse { throw AppleTokenInvalidException("Apple identity claims are invalid", it) }
val now = clock.instant()
if (claims.issuer != APPLE_ISSUER || claims.audience != listOf(config.clientId)) {
throw AppleTokenInvalidException("Apple identity token issuer or audience is invalid")
}
val expiresAt = claims.expirationTime?.toInstant()
if (expiresAt?.isAfter(now.minusSeconds(CLOCK_SKEW_SECONDS)) != true) {
throw AppleTokenInvalidException("Apple identity token has expired")
}
val issuedAt = claims.issueTime?.toInstant()
?: throw AppleTokenInvalidException("Apple identity token is missing iat")
if (issuedAt.isAfter(now.plusSeconds(CLOCK_SKEW_SECONDS)) || !expiresAt.isAfter(issuedAt)) {
throw AppleTokenInvalidException("Apple identity token time claims are invalid")
}
val actualNonce = runCatching { claims.getStringClaim(NONCE_CLAIM) }
.getOrElse { throw AppleTokenInvalidException("Apple identity token nonce is invalid", it) }
?: throw AppleTokenInvalidException("Apple identity token is missing nonce")
if (!AppleNonceVerifier.matches(expectedNonce, actualNonce)) {
throw AppleTokenInvalidException("Apple identity token nonce is invalid")
}
val subject = claims.subject?.takeIf { it.isNotBlank() && it.length <= MAX_SUBJECT_LENGTH }
?: throw AppleTokenInvalidException("Apple identity token is missing sub")
return AppleIdentity(subject)
}
}
internal fun RSAKey.isSuitableAppleSigningKey(expectedKeyId: String): Boolean = runCatching {
val operations = keyOperations
keyID == expectedKeyId &&
(algorithm == null || algorithm == JWSAlgorithm.RS256) &&
(keyUse == null || keyUse == KeyUse.SIGNATURE) &&
(operations.isNullOrEmpty() || KeyOperation.VERIFY in operations) &&
toRSAPublicKey().let { publicKey ->
publicKey.modulus.bitLength() >= MIN_RSA_KEY_BITS &&
publicKey.publicExponent >= MIN_RSA_PUBLIC_EXPONENT &&
publicKey.publicExponent.testBit(0)
}
}.getOrDefault(false)
/**
* Apple receives SHA-256(raw nonce) from the client. The server receives the
* original nonce and compares only its digest with the signed claim.
*/
internal object AppleNonceVerifier {
fun matches(rawNonce: String, signedClaim: String): Boolean {
if (rawNonce.isBlank() || !signedClaim.matches(SHA256_HEX)) return false
val expected = MessageDigest.getInstance("SHA-256")
.digest(rawNonce.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
return MessageDigest.isEqual(
expected.toByteArray(Charsets.US_ASCII),
signedClaim.lowercase().toByteArray(Charsets.US_ASCII),
)
}
}
class AppleTokenInvalidException(message: String, cause: Throwable? = null) :
SecurityException(message, cause)
class AppleVerificationUnavailableException(message: String, cause: Throwable? = null) :
IllegalStateException(message, cause)
private const val APPLE_ISSUER = "https://appleid.apple.com"
private const val NONCE_CLAIM = "nonce"
private const val MAX_SUBJECT_LENGTH = 128
private const val MIN_RSA_KEY_BITS = 2048
private const val MAX_IDENTITY_TOKEN_LENGTH = 16_384
private const val MAX_NONCE_LENGTH = 256
private const val MAX_JWK_COUNT = 20
private const val CLOCK_SKEW_SECONDS = 30L
private val SHA256_HEX = Regex("[A-Fa-f0-9]{64}")
private val KEY_MISS_REFRESH_INTERVAL: Duration = Duration.ofMinutes(1)
private val MAX_JWKS_CACHE_TTL: Duration = Duration.ofHours(24)
private val MIN_RSA_PUBLIC_EXPONENT: BigInteger = BigInteger.valueOf(65_537)
@@ -0,0 +1,243 @@
package com.osglab.account.features.auth
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.ECDSASigner
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import io.ktor.client.HttpClient
import io.ktor.client.plugins.timeout
import io.ktor.client.request.forms.submitForm
import io.ktor.client.statement.bodyAsText
import io.ktor.http.Parameters
import io.ktor.http.isSuccess
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.security.AlgorithmParameters
import java.security.KeyFactory
import java.security.interfaces.ECPrivateKey
import java.security.spec.ECGenParameterSpec
import java.security.spec.ECParameterSpec
import java.security.spec.PKCS8EncodedKeySpec
import java.time.Clock
import java.time.Duration
import java.util.Base64
import java.util.Date
data class AppleTokenExchange(
val refreshToken: String,
val identityToken: String,
) {
override fun toString(): String =
"AppleTokenExchange(refreshToken=[REDACTED], identityToken=[REDACTED])"
}
interface AppleTokenClient {
suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange
suspend fun revokeRefreshToken(refreshToken: String)
}
fun interface AppleClientSecretSigner {
fun create(): String
}
fun createAppleTokenClient(httpClient: HttpClient, config: AppleConfig): AppleTokenClient =
if (config.clientCredentialsAvailable) {
HttpAppleTokenClient(httpClient, config, AppleClientSecretProvider(config))
} else {
UnavailableAppleTokenClient()
}
class HttpAppleTokenClient(
private val httpClient: HttpClient,
private val config: AppleConfig,
private val clientSecretProvider: AppleClientSecretSigner,
private val json: Json = Json { ignoreUnknownKeys = true },
private val requestTimeoutMillis: Long = APPLE_REQUEST_TIMEOUT_MILLIS,
) : AppleTokenClient {
init {
require(requestTimeoutMillis > 0) { "Apple request timeout must be positive" }
}
override suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange {
requireSecretSize(code, "authorization code", MAX_AUTHORIZATION_CODE_LENGTH)
val response = request(
url = config.tokenUrl,
parameters = Parameters.build {
append("client_id", config.clientId)
append("client_secret", clientSecretProvider.create())
append("code", code)
append("grant_type", "authorization_code")
},
)
val payload = runCatching { json.decodeFromString<AppleTokenResponse>(response.body) }
.getOrNull()
if (!response.success || payload?.error != null) {
throw AppleTokenEndpointException(
"Apple rejected the authorization code",
retryable = response.status.isRetryableAppleStatus(),
)
}
if (payload == null) {
throw AppleTokenEndpointException("Apple token response was invalid", true)
}
val identityToken = payload.identityToken
?.takeIf { it.isNotBlank() && it.length <= MAX_IDENTITY_TOKEN_LENGTH }
?: throw AppleTokenEndpointException("Apple token response omitted a valid id_token", false)
val refreshToken = payload.refreshToken
?.takeIf { it.isNotBlank() && it.length <= MAX_APPLE_REFRESH_TOKEN_LENGTH }
?: throw AppleTokenEndpointException("Apple token response omitted a valid refresh_token", false)
return AppleTokenExchange(refreshToken, identityToken)
}
override suspend fun revokeRefreshToken(refreshToken: String) {
requireSecretSize(refreshToken, "Apple refresh token", MAX_APPLE_REFRESH_TOKEN_LENGTH)
val response = request(
url = config.revokeUrl,
parameters = Parameters.build {
append("client_id", config.clientId)
append("client_secret", clientSecretProvider.create())
append("token", refreshToken)
append("token_type_hint", "refresh_token")
},
)
if (!response.success) {
throw AppleTokenEndpointException(
"Apple token revocation failed",
retryable = response.status.isRetryableAppleStatus(),
)
}
}
private fun requireSecretSize(value: String, label: String, maximumLength: Int) {
if (value.isBlank() || value.length > maximumLength) {
throw AppleTokenEndpointException("$label has an invalid size", false)
}
}
private suspend fun request(url: String, parameters: Parameters): AppleHttpResponse {
val response = runCatching {
httpClient.submitForm(url = url, formParameters = parameters) {
timeout {
connectTimeoutMillis = requestTimeoutMillis
requestTimeoutMillis = requestTimeoutMillis
socketTimeoutMillis = requestTimeoutMillis
}
}
}
.getOrElse { throw AppleTokenEndpointException("Apple token endpoint is unavailable", true, it) }
return AppleHttpResponse(
success = response.status.isSuccess(),
status = response.status.value,
body = response.bodyAsText(),
)
}
private data class AppleHttpResponse(
val success: Boolean,
val status: Int,
val body: String,
)
}
class AppleClientSecretProvider(
private val config: AppleConfig,
private val clock: Clock = Clock.systemUTC(),
) : AppleClientSecretSigner {
private val privateKey: ECPrivateKey by lazy(::loadPrivateKey)
override fun create(): String {
val teamId = config.teamId?.takeIf(String::isNotBlank)
?: throw AppleClientUnavailableException()
val keyId = config.keyId?.takeIf(String::isNotBlank)
?: throw AppleClientUnavailableException()
if (config.clientId.isBlank()) throw AppleClientUnavailableException()
if (config.privateKeyPem == null) throw AppleClientUnavailableException()
val now = clock.instant()
val claims = JWTClaimsSet.Builder()
.issuer(teamId)
.subject(config.clientId)
.audience(APPLE_ISSUER)
.issueTime(Date.from(now))
.expirationTime(Date.from(now.plus(CLIENT_SECRET_LIFETIME)))
.build()
val jwt = SignedJWT(
JWSHeader.Builder(JWSAlgorithm.ES256).keyID(keyId).build(),
claims,
)
jwt.sign(ECDSASigner(privateKey))
return jwt.serialize()
}
private fun loadPrivateKey(): ECPrivateKey {
val pem = config.privateKeyPem?.trim() ?: throw AppleClientUnavailableException()
if (!pem.startsWith(PKCS8_PEM_BEGIN) || !pem.endsWith(PKCS8_PEM_END)) {
throw AppleClientUnavailableException("Apple private key must be PKCS#8 PEM")
}
val encoded = pem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace(Regex("\\s"), "")
return runCatching {
val key = KeyFactory.getInstance("EC")
.generatePrivate(PKCS8EncodedKeySpec(Base64.getDecoder().decode(encoded))) as ECPrivateKey
requireP256(key)
key
}.getOrElse {
throw AppleClientUnavailableException("Apple private key is invalid", it)
}
}
private fun requireP256(key: ECPrivateKey) {
val expected = AlgorithmParameters.getInstance("EC").run {
init(ECGenParameterSpec("secp256r1"))
getParameterSpec(ECParameterSpec::class.java)
}
require(key.params.curve == expected.curve &&
key.params.generator == expected.generator &&
key.params.order == expected.order &&
key.params.cofactor == expected.cofactor
) {
"Apple private key must use P-256"
}
}
}
class UnavailableAppleTokenClient(
private val reason: String = "Apple client credentials are not configured",
) : AppleTokenClient {
override suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange =
throw AppleClientUnavailableException(reason)
override suspend fun revokeRefreshToken(refreshToken: String): Unit =
throw AppleClientUnavailableException(reason)
}
class AppleClientUnavailableException(message: String = "Apple token client is unavailable", cause: Throwable? = null) :
IllegalStateException(message, cause)
class AppleTokenEndpointException(
message: String,
val retryable: Boolean,
cause: Throwable? = null,
) : IllegalStateException(message, cause)
@Serializable
private data class AppleTokenResponse(
@SerialName("refresh_token") val refreshToken: String? = null,
@SerialName("id_token") val identityToken: String? = null,
val error: String? = null,
)
private fun Int.isRetryableAppleStatus(): Boolean = this == 408 || this == 429 || this >= 500
private const val APPLE_ISSUER = "https://appleid.apple.com"
private const val APPLE_REQUEST_TIMEOUT_MILLIS = 10_000L
private const val MAX_AUTHORIZATION_CODE_LENGTH = 2_048
private const val MAX_APPLE_REFRESH_TOKEN_LENGTH = 4_096
private const val MAX_IDENTITY_TOKEN_LENGTH = 16_384
private const val PKCS8_PEM_BEGIN = "-----BEGIN PRIVATE KEY-----"
private const val PKCS8_PEM_END = "-----END PRIVATE KEY-----"
private val CLIENT_SECRET_LIFETIME: Duration = Duration.ofMinutes(5)
@@ -0,0 +1,346 @@
package com.osglab.account.features.auth
import com.osglab.account.config.DatabaseFactory
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.Instant
import java.util.UUID
internal object AccountsTable : Table("accounts") {
val id = varchar("id", 36)
// The legacy column name is retained by V1, but its value is always AES-GCM ciphertext.
val encryptedAppleSubject = varchar("apple_sub", 255).uniqueIndex()
val identityFingerprint = char("identity_fingerprint", 64).nullable().uniqueIndex()
val antiAbuseRestricted = bool("anti_abuse_restricted")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
internal object AppleCredentialsTable : Table("apple_credentials") {
val accountId = varchar("account_id", 36)
val encryptedRefreshToken = text("encrypted_refresh_token")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(accountId)
}
internal object AccountIdentityTombstonesTable : Table("account_identity_tombstones") {
val identityFingerprint = char("identity_fingerprint", 64)
val deletedAt = timestamp("deleted_at")
val expiresAt = timestamp("expires_at")
override val primaryKey = PrimaryKey(identityFingerprint)
}
internal object SessionsTable : Table("sessions") {
val id = varchar("id", 36)
val accountId = varchar("account_id", 36).index()
val familyId = varchar("family_id", 36).index()
val refreshTokenHash = varchar("refresh_token_hash", 64).uniqueIndex()
val replacedById = varchar("replaced_by_id", 36).nullable()
val createdAt = timestamp("created_at")
val expiresAt = timestamp("expires_at")
val revokedAt = timestamp("revoked_at").nullable()
val reuseDetectedAt = timestamp("reuse_detected_at").nullable()
override val primaryKey = PrimaryKey(id)
}
data class AuthAccount(
val id: UUID,
val identityFingerprint: String,
val antiAbuseRestricted: Boolean,
)
data class CreatedSession(
val accountId: UUID,
val sessionId: UUID,
val familyId: UUID,
)
sealed interface RefreshRotationResult {
data class Rotated(
val accountId: UUID,
val sessionId: UUID,
val familyId: UUID,
) : RefreshRotationResult
data object Invalid : RefreshRotationResult
data object ReuseDetected : RefreshRotationResult
}
internal enum class RefreshRotationDecision {
ROTATE,
REVOKE_EXPIRED,
REVOKE_REUSED_FAMILY,
}
/**
* Keeps the security-sensitive refresh state transition independent from SQL,
* so every repository implementation applies the same replay policy.
*/
internal object RefreshRotationPolicy {
fun decide(
revoked: Boolean,
replaced: Boolean,
expiresAt: Instant,
now: Instant,
): RefreshRotationDecision = when {
revoked || replaced -> RefreshRotationDecision.REVOKE_REUSED_FAMILY
!expiresAt.isAfter(now) -> RefreshRotationDecision.REVOKE_EXPIRED
else -> RefreshRotationDecision.ROTATE
}
}
interface AuthRepository {
suspend fun findOrCreateAccount(
identityFingerprint: String,
encryptedAppleSubject: String,
now: Instant,
): AuthAccount
suspend fun updateAppleRefreshToken(accountId: UUID, encryptedToken: String, now: Instant)
suspend fun createSession(
accountId: UUID,
refreshTokenHash: String,
expiresAt: Instant,
now: Instant,
): CreatedSession
suspend fun rotateRefreshToken(
currentTokenHash: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): RefreshRotationResult
suspend fun revokeSessionFamily(accountId: UUID, sessionId: UUID, now: Instant): Boolean
suspend fun isSessionActive(accountId: UUID, sessionId: UUID, now: Instant): Boolean
suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant)
}
class ExposedAuthRepository(
private val databaseFactory: DatabaseFactory,
) : AuthRepository {
override suspend fun findOrCreateAccount(
identityFingerprint: String,
encryptedAppleSubject: String,
now: Instant,
): AuthAccount =
databaseFactory.withAppleIdentityLock(identityFingerprint) {
databaseFactory.query {
val restricted = AccountIdentityTombstonesTable.selectAll()
.where {
(AccountIdentityTombstonesTable.identityFingerprint eq identityFingerprint) and
(AccountIdentityTombstonesTable.expiresAt greater now)
}
.singleOrNull() != null
val id = UUID.randomUUID()
AccountsTable.insertIgnore {
it[AccountsTable.id] = id.toString()
it[AccountsTable.encryptedAppleSubject] = encryptedAppleSubject
it[AccountsTable.identityFingerprint] = identityFingerprint
it[AccountsTable.antiAbuseRestricted] = restricted
it[AccountsTable.createdAt] = now
it[AccountsTable.updatedAt] = now
}
AccountsTable.selectAll()
.where { AccountsTable.identityFingerprint eq identityFingerprint }
.single()
.toAuthAccount()
}
}
override suspend fun updateAppleRefreshToken(
accountId: UUID,
encryptedToken: String,
now: Instant,
) {
val fingerprint = databaseFactory.query {
AccountsTable.selectAll()
.where { AccountsTable.id eq accountId.toString() }
.singleOrNull()
?.get(AccountsTable.identityFingerprint)
} ?: return
databaseFactory.withAppleIdentityLock(fingerprint) {
databaseFactory.query {
val accountStillExists = AccountsTable.selectAll()
.where {
(AccountsTable.id eq accountId.toString()) and
(AccountsTable.identityFingerprint eq fingerprint)
}
.limit(1)
.singleOrNull() != null
check(accountStillExists) { "Account was deleted during Apple sign-in" }
AppleCredentialsTable.insertIgnore {
it[AppleCredentialsTable.accountId] = accountId.toString()
it[AppleCredentialsTable.encryptedRefreshToken] = encryptedToken
it[AppleCredentialsTable.createdAt] = now
it[AppleCredentialsTable.updatedAt] = now
}
AppleCredentialsTable.update({
AppleCredentialsTable.accountId eq accountId.toString()
}) {
it[encryptedRefreshToken] = encryptedToken
it[updatedAt] = now
}
}
}
}
override suspend fun createSession(
accountId: UUID,
refreshTokenHash: String,
expiresAt: Instant,
now: Instant,
): CreatedSession = databaseFactory.query {
val sessionId = UUID.randomUUID()
val familyId = sessionId
SessionsTable.insert {
it[SessionsTable.id] = sessionId.toString()
it[SessionsTable.accountId] = accountId.toString()
it[SessionsTable.familyId] = familyId.toString()
it[SessionsTable.refreshTokenHash] = refreshTokenHash
it[SessionsTable.createdAt] = now
it[SessionsTable.expiresAt] = expiresAt
}
CreatedSession(accountId, sessionId, familyId)
}
override suspend fun rotateRefreshToken(
currentTokenHash: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): RefreshRotationResult = databaseFactory.query {
val current = SessionsTable.selectAll()
.where { SessionsTable.refreshTokenHash eq currentTokenHash }
.forUpdate()
.singleOrNull()
?: return@query RefreshRotationResult.Invalid
val familyId = current[SessionsTable.familyId]
when (
RefreshRotationPolicy.decide(
revoked = current[SessionsTable.revokedAt] != null,
replaced = current[SessionsTable.replacedById] != null,
expiresAt = current[SessionsTable.expiresAt],
now = now,
)
) {
RefreshRotationDecision.REVOKE_REUSED_FAMILY -> {
SessionsTable.update({ SessionsTable.familyId eq familyId }) {
it[SessionsTable.revokedAt] = now
}
SessionsTable.update({ SessionsTable.id eq current[SessionsTable.id] }) {
it[SessionsTable.reuseDetectedAt] = now
}
return@query RefreshRotationResult.ReuseDetected
}
RefreshRotationDecision.REVOKE_EXPIRED -> {
SessionsTable.update({ SessionsTable.familyId eq familyId }) {
it[SessionsTable.revokedAt] = now
}
return@query RefreshRotationResult.Invalid
}
RefreshRotationDecision.ROTATE -> Unit
}
val newSessionId = UUID.randomUUID()
SessionsTable.insert {
it[SessionsTable.id] = newSessionId.toString()
it[SessionsTable.accountId] = current[SessionsTable.accountId]
it[SessionsTable.familyId] = familyId
it[SessionsTable.refreshTokenHash] = newTokenHash
it[SessionsTable.createdAt] = now
it[SessionsTable.expiresAt] = newExpiresAt
}
SessionsTable.update({ SessionsTable.id eq current[SessionsTable.id] }) {
it[SessionsTable.replacedById] = newSessionId.toString()
it[SessionsTable.revokedAt] = now
}
RefreshRotationResult.Rotated(
accountId = UUID.fromString(current[SessionsTable.accountId]),
sessionId = newSessionId,
familyId = UUID.fromString(familyId),
)
}
override suspend fun revokeSessionFamily(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = databaseFactory.query {
val session = SessionsTable.selectAll()
.where {
(SessionsTable.id eq sessionId.toString()) and
(SessionsTable.accountId eq accountId.toString())
}
.forUpdate()
.singleOrNull()
?: return@query false
SessionsTable.update({
(SessionsTable.accountId eq accountId.toString()) and
(SessionsTable.familyId eq session[SessionsTable.familyId])
}) {
it[SessionsTable.revokedAt] = now
} > 0
}
override suspend fun isSessionActive(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = databaseFactory.query {
val accountExists = AccountsTable.selectAll()
.where { AccountsTable.id eq accountId.toString() }
.limit(1)
.singleOrNull() != null
accountExists && SessionsTable.selectAll()
.where {
(SessionsTable.id eq sessionId.toString()) and
(SessionsTable.accountId eq accountId.toString()) and
SessionsTable.revokedAt.isNull() and
(SessionsTable.expiresAt greater now)
}
.limit(1)
.singleOrNull() != null
}
override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) {
databaseFactory.query {
AccountsTable.update({ AccountsTable.id eq accountId.toString() }) {
it[antiAbuseRestricted] = true
it[updatedAt] = now
}
}
}
}
private fun ResultRow.toAuthAccount(): AuthAccount = AuthAccount(
id = UUID.fromString(this[AccountsTable.id]),
identityFingerprint = requireNotNull(this[AccountsTable.identityFingerprint]),
antiAbuseRestricted = this[AccountsTable.antiAbuseRestricted],
)
internal suspend fun <T> DatabaseFactory.withAppleIdentityLock(
identityFingerprint: String,
block: suspend () -> T,
): T {
require(identityFingerprint.length == IDENTITY_FINGERPRINT_LENGTH)
return withMysqlNamedLock(
"apple-id:${identityFingerprint.take(IDENTITY_LOCK_FINGERPRINT_LENGTH)}",
IDENTITY_LOCK_TIMEOUT_SECONDS,
block,
)
}
private const val IDENTITY_FINGERPRINT_LENGTH = 64
private const val IDENTITY_LOCK_FINGERPRINT_LENGTH = 55
private const val IDENTITY_LOCK_TIMEOUT_SECONDS = 10
@@ -0,0 +1,118 @@
package com.osglab.account.features.auth
import com.osglab.account.common.api.ApiResponse
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.SESSION_AUTH_NAME
import com.osglab.account.features.integrity.AppAttestEvidence
import com.osglab.account.features.integrity.IntegrityEvidence
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.principal
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
@Serializable
data class AppleSignInRequest(
val identityToken: String,
val authorizationCode: String,
val nonce: String,
val deviceCheckToken: String? = null,
val appAttest: AppAttestRequest? = null,
) {
override fun toString(): String =
"AppleSignInRequest(identityToken=[REDACTED], authorizationCode=[REDACTED], " +
"nonce=[REDACTED], deviceCheckToken=[REDACTED], appAttest=[REDACTED])"
}
@Serializable
data class AppAttestRequest(
val keyId: String,
val challengeId: String,
val challenge: String,
val assertion: String,
) {
override fun toString(): String =
"AppAttestRequest(keyId=[REDACTED], challengeId=[REDACTED], " +
"challenge=[REDACTED], assertion=[REDACTED])"
}
@Serializable
data class RefreshSessionRequest(val refreshToken: String) {
override fun toString(): String = "RefreshSessionRequest(refreshToken=[REDACTED])"
}
@Serializable
data class SessionTokenResponse(
val accountId: String,
val tokenType: String = "Bearer",
val accessToken: String,
val accessTokenExpiresAtEpochSeconds: Long,
val refreshToken: String,
val refreshTokenExpiresAtEpochSeconds: Long,
) {
override fun toString(): String =
"SessionTokenResponse(accountId=$accountId, tokenType=$tokenType, " +
"accessToken=[REDACTED], accessTokenExpiresAtEpochSeconds=$accessTokenExpiresAtEpochSeconds, " +
"refreshToken=[REDACTED], refreshTokenExpiresAtEpochSeconds=$refreshTokenExpiresAtEpochSeconds)"
}
class AuthRoutes(
private val sessionService: SessionService,
) {
fun register(parent: Route) {
with(parent) {
route("/v1/auth") {
post("/apple") {
val request = call.receive<AppleSignInRequest>()
val tokens = sessionService.signInWithApple(
identityToken = request.identityToken,
authorizationCode = request.authorizationCode,
nonce = request.nonce,
integrityEvidence = IntegrityEvidence(
deviceCheckToken = request.deviceCheckToken,
appAttest = request.appAttest?.let {
AppAttestEvidence(
keyId = it.keyId,
challengeId = it.challengeId,
assertion = it.assertion,
challenge = it.challenge,
)
},
),
)
call.respond(ApiResponse(data = tokens.toResponse()))
}
post("/refresh") {
val request = call.receive<RefreshSessionRequest>()
call.respond(
ApiResponse(data = sessionService.refresh(request.refreshToken).toResponse()),
)
}
authenticate(SESSION_AUTH_NAME) {
post("/logout") {
val principal = call.principal<AccountPrincipal>()
?: throw UnauthorizedException()
sessionService.logout(principal)
call.respond(HttpStatusCode.NoContent)
}
}
}
}
}
}
fun Route.authRoutes(sessionService: SessionService) =
AuthRoutes(sessionService).register(this)
private fun SessionTokens.toResponse(): SessionTokenResponse = SessionTokenResponse(
accountId = accountId.toString(),
accessToken = accessToken,
accessTokenExpiresAtEpochSeconds = accessTokenExpiresAt.epochSecond,
refreshToken = refreshToken,
refreshTokenExpiresAtEpochSeconds = refreshTokenExpiresAt.epochSecond,
)
@@ -0,0 +1,23 @@
package com.osglab.account.features.auth
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.SessionJwt
import java.time.Clock
/**
* Access tokens are accepted only while their account and refresh-token family
* still exist and remain active. This makes logout, replay response and account
* deletion immediately effective for every authenticated request.
*/
class SessionAccessAuthenticator(
private val sessionJwt: SessionJwt,
private val repository: AuthRepository,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun authenticate(serialized: String): AccountPrincipal? {
val principal = sessionJwt.verify(serialized) ?: return null
return principal.takeIf {
repository.isSessionActive(it.userId, it.sessionId, clock.instant())
}
}
}
@@ -0,0 +1,185 @@
package com.osglab.account.features.auth
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.common.errors.TokenReuseException
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.common.security.SecureTokenGenerator
import com.osglab.account.common.security.Sha256SecureTokenGenerator
import com.osglab.account.common.security.SessionJwt
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.TokenHash
import com.osglab.account.config.SessionConfig
import com.osglab.account.features.integrity.AppleSignInIntegrityPayload
import com.osglab.account.features.integrity.IntegrityEvidence
import com.osglab.account.features.integrity.IntegrityService
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.UUID
data class SessionTokens(
val accountId: UUID,
val accessToken: String,
val accessTokenExpiresAt: Instant,
val refreshToken: String,
val refreshTokenExpiresAt: Instant,
) {
override fun toString(): String =
"SessionTokens(accountId=$accountId, accessToken=[REDACTED], " +
"accessTokenExpiresAt=$accessTokenExpiresAt, refreshToken=[REDACTED], " +
"refreshTokenExpiresAt=$refreshTokenExpiresAt)"
}
fun interface AccountProvisioner {
suspend fun provision(accountId: UUID, deviceCheckToken: String?)
}
class SessionService(
private val repository: AuthRepository,
private val appleIdentityVerifier: AppleIdentityTokenVerifier,
private val appleTokenClient: AppleTokenClient,
private val integrityService: IntegrityService,
private val sessionJwt: SessionJwt,
private val fieldEncryptor: FieldEncryptor,
private val identityFingerprint: IdentityFingerprint,
private val sessionConfig: SessionConfig,
private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _ -> },
private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(),
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun signInWithApple(
identityToken: String,
authorizationCode: String,
nonce: String,
integrityEvidence: IntegrityEvidence,
): SessionTokens {
requireValue(identityToken, "identityToken", MAX_IDENTITY_TOKEN_LENGTH)
requireValue(authorizationCode, "authorizationCode", MAX_AUTHORIZATION_CODE_LENGTH)
requireValue(nonce, "nonce", MAX_NONCE_LENGTH)
val verifiedIntegrity = integrityService.verifyAppleSignIn(
integrityEvidence,
AppleSignInIntegrityPayload(identityToken, authorizationCode, nonce),
)
val suppliedIdentity = verifyIdentityToken(identityToken, nonce)
val exchange = exchangeCode(authorizationCode)
val exchangedIdentity = verifyIdentityToken(exchange.identityToken, nonce)
if (suppliedIdentity.subject != exchangedIdentity.subject) {
throw UnauthorizedException("Apple authorization code does not match identity token")
}
val now = clock.instant()
val fingerprint = identityFingerprint.ofAppleSubject(suppliedIdentity.subject)
val account = repository.findOrCreateAccount(
identityFingerprint = fingerprint,
encryptedAppleSubject = fieldEncryptor.encrypt(
suppliedIdentity.subject,
appleSubjectContext(fingerprint),
),
now = now,
)
integrityService.bindVerifiedKey(verifiedIntegrity.appAttestKeyId, account.id)
repository.updateAppleRefreshToken(
account.id,
fieldEncryptor.encrypt(exchange.refreshToken, appleRefreshContext(account.id)),
now,
)
accountProvisioner.provision(
account.id,
verifiedIntegrity.deviceCheckTokenForTrial.takeUnless { account.antiAbuseRestricted },
)
return createSession(account.id, now)
}
suspend fun refresh(refreshToken: String): SessionTokens {
requireValue(refreshToken, "refreshToken", MAX_REFRESH_TOKEN_LENGTH)
val now = clock.instant()
val replacement = tokenGenerator.newRefreshToken()
val replacementExpiresAt = now.plus(Duration.ofDays(sessionConfig.refreshDays))
return when (
val result = repository.rotateRefreshToken(
currentTokenHash = TokenHash.sha256(refreshToken),
newTokenHash = TokenHash.sha256(replacement),
newExpiresAt = replacementExpiresAt,
now = now,
)
) {
RefreshRotationResult.Invalid -> throw UnauthorizedException("Refresh token is invalid or expired")
RefreshRotationResult.ReuseDetected -> throw TokenReuseException()
is RefreshRotationResult.Rotated -> {
val access = sessionJwt.issue(result.accountId, result.sessionId)
SessionTokens(
accountId = result.accountId,
accessToken = access.value,
accessTokenExpiresAt = access.expiresAt,
refreshToken = replacement,
refreshTokenExpiresAt = replacementExpiresAt,
)
}
}
}
suspend fun logout(principal: AccountPrincipal) {
repository.revokeSessionFamily(principal.userId, principal.sessionId, clock.instant())
}
private suspend fun createSession(accountId: UUID, now: Instant): SessionTokens {
val refreshToken = tokenGenerator.newRefreshToken()
val refreshExpiresAt = now.plus(Duration.ofDays(sessionConfig.refreshDays))
val created = repository.createSession(
accountId = accountId,
refreshTokenHash = TokenHash.sha256(refreshToken),
expiresAt = refreshExpiresAt,
now = now,
)
val access = sessionJwt.issue(accountId, created.sessionId)
return SessionTokens(
accountId = accountId,
accessToken = access.value,
accessTokenExpiresAt = access.expiresAt,
refreshToken = refreshToken,
refreshTokenExpiresAt = refreshExpiresAt,
)
}
private suspend fun verifyIdentityToken(token: String, nonce: String): AppleIdentity =
try {
appleIdentityVerifier.verify(token, nonce)
} catch (exception: AppleVerificationUnavailableException) {
throw ExternalServiceUnavailableException("Apple identity verification")
} catch (exception: AppleTokenInvalidException) {
throw UnauthorizedException("Apple identity token is invalid")
}
private suspend fun exchangeCode(code: String): AppleTokenExchange =
try {
appleTokenClient.exchangeAuthorizationCode(code)
} catch (exception: AppleClientUnavailableException) {
throw ExternalServiceUnavailableException("Apple token service")
} catch (exception: AppleTokenEndpointException) {
if (exception.retryable) {
throw ExternalServiceUnavailableException("Apple token service")
}
throw UnauthorizedException("Apple authorization code is invalid")
}
private fun requireValue(value: String, name: String, maxLength: Int) {
if (value.isBlank()) throw InvalidRequestException("$name must not be blank")
if (value.length > maxLength) {
throw InvalidRequestException("$name exceeds the maximum length")
}
}
private companion object {
const val MAX_IDENTITY_TOKEN_LENGTH = 16_384
const val MAX_AUTHORIZATION_CODE_LENGTH = 2_048
const val MAX_NONCE_LENGTH = 256
const val MAX_REFRESH_TOKEN_LENGTH = 512
}
}
fun appleRefreshContext(accountId: UUID): String = "apple-refresh-token:$accountId"
fun appleSubjectContext(identityFingerprint: String): String = "apple-subject:$identityFingerprint"