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