Files
OSGAccountServer/src/main/kotlin/com/osglab/account/features/auth/SessionService.kt
T
Rocky 0af35d44f4 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.
2026-08-16 14:46:23 +08:00

186 lines
8.0 KiB
Kotlin

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"