0af35d44f4
Provide the production foundation for Apple identity, immutable credits, referrals, integrity checks, managed providers, and hardened Docker deployment.
186 lines
8.0 KiB
Kotlin
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"
|