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"