fix(member-social): 애플 로그인 aud 검증에 serviceId를 포함한다

This commit is contained in:
2026-03-30 09:21:59 +09:00
parent 2160e7b9dd
commit a4ffab0351
4 changed files with 109 additions and 3 deletions

View File

@@ -0,0 +1,50 @@
# 애플 로그인 aud 검증 실패 원인 분석
## 구현/분석 항목
- [x] `/member/login/apple` 요청 흐름과 `AppleIdentityTokenVerifier` 검증 로직을 확인한다.
QA: 관련 코드 경로와 실제 비교값(`audience` vs 설정값)을 파일 근거로 정리한다.
- [x] Apple Identity Token의 `aud` 규칙(웹 Service ID / 네이티브 Bundle ID)을 확인해 실패 원인을 확정한다.
QA: 공식 문서/신뢰 가능한 레퍼런스 근거를 함께 기록한다.
- [x] 필요 시 서버 검증 로직을 수정해 웹/앱 로그인 환경과 일치시키고, 불필요하면 수정하지 않는다.
QA: 수정 전/후 조건을 비교해 실패 지점 해소 여부를 설명한다.
- [x] 변경 사항에 대해 정적/실행 검증을 수행한다.
QA: 실행 명령과 성공/실패 결과를 기록한다.
## 검증 기록
- 1차 분석: 진행 전
- 무엇을: 애플 로그인 aud 검증 실패 재현 경로 분석을 시작했다.
- 왜: 62번째 줄 audience 검증 실패 원인을 코드/설정/외부 규격 기준으로 확정하기 위해서다.
- 어떻게: 코드 검색, 외부 문서 조사, 필요 시 테스트/빌드 검증을 수행할 계획이다.
- 2차 분석: 실패 원인 확정
- 무엇을: `/member/login/apple` 호출 경로와 Apple 토큰 audience 비교 대상을 확인했다.
- 왜: 실제 실패 지점이 검증 로직 문제인지, 설정 누락인지를 분리하기 위해서다.
- 어떻게: `MemberController.loginApple``AppleAuthService.authenticate``AppleIdentityTokenVerifier.validateClaims` 흐름을 확인했고,
`claims.audience.contains(bundleId)`(기존 62줄) 비교가 `apple.bundle-id` 단일값에만 의존함을 확인했다.
- 3차 분석: 외부 규격 대조
- 무엇을: Apple 공식 문서 기준으로 `id_token.aud` 의미를 확인했다.
- 왜: 웹 로그인에서 `aud` 기대값이 Bundle ID인지 Service ID인지 확정해야 수정 기준이 생긴다.
- 어떻게: Apple 문서에서 `aud == client_id`, 웹 Sign in with Apple JS는 `client_id`로 Service ID를 사용함을 확인했다.
따라서 웹 토큰의 `aud`가 Service ID일 때 기존 bundleId 단일 비교는 실패가 정상임을 확정했다.
- 4차 구현: 검증 로직 보완
- 무엇을: Apple 로그인 audience 검증 대상을 `bundleId` + `serviceId`로 확장했다.
- 왜: 웹(Service ID)과 앱(Bundle ID) 토큰 모두 동일 백엔드 검증 로직에서 처리하기 위해서다.
- 어떻게:
- `src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifier.kt`
- `@Value("\${apple.service-id:}")` 추가
- `resolveExpectedAudiences()`로 유효 audience 집합 생성
- `isSupportedAudience()``claims.audience` 교집합 검증
- `src/main/resources/application.yml`
- `apple.serviceId: ${APPLE_SERVICE_ID:}` 추가
- `src/test/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifierTest.kt`
- bundleId/serviceId 허용 및 미일치 거부 케이스 추가
- 5차 검증: 정적/실행 확인
- 무엇을: 변경 코드의 테스트/린트/빌드를 수행했다.
- 왜: audience 로직 변경이 실제로 컴파일/테스트/스타일 검증을 통과하는지 확인하기 위해서다.
- 어떻게:
- `lsp_diagnostics` (Kotlin 파일): 로컬 환경에 `.kt` LSP 서버 미설정으로 도구 진단 불가(환경 제약 확인)
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.social.apple.AppleIdentityTokenVerifierTest"` → 성공
- `./gradlew ktlintCheck build -x test` → 성공

View File

@@ -20,7 +20,9 @@ import java.util.Date
@Service
class AppleIdentityTokenVerifier(
@Value("\${apple.bundle-id}")
private val bundleId: String
private val bundleId: String,
@Value("\${apple.service-id}")
private val serviceId: String
) {
private val jwkUrl = URL("https://appleid.apple.com/auth/keys")
private val jwkSource: JWKSource<SecurityContext> = JWKSourceBuilder.create<SecurityContext>(jwkUrl)
@@ -32,7 +34,8 @@ class AppleIdentityTokenVerifier(
}
fun verify(identityToken: String, rawNonce: String): AppleUserInfo {
if (bundleId.isBlank()) {
val expectedAudiences = resolveExpectedAudiences()
if (expectedAudiences.isEmpty()) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
@@ -59,7 +62,7 @@ class AppleIdentityTokenVerifier(
throw SodaException(messageKey = "member.social.apple_login_failed")
}
if (!claims.audience.contains(bundleId)) {
if (!isSupportedAudience(claims.audience)) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
@@ -81,6 +84,18 @@ class AppleIdentityTokenVerifier(
}
}
internal fun isSupportedAudience(audience: List<String>): Boolean {
val expectedAudiences = resolveExpectedAudiences()
return expectedAudiences.isNotEmpty() && audience.any { expectedAudiences.contains(it) }
}
private fun resolveExpectedAudiences(): Set<String> {
return setOf(bundleId, serviceId)
.map { it.trim() }
.filter { it.isNotBlank() }
.toSet()
}
private fun hashNonce(rawNonce: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hashed = digest.digest(rawNonce.toByteArray(StandardCharsets.UTF_8))

View File

@@ -34,6 +34,7 @@ apple:
iapVerifyUrl: https://buy.itunes.apple.com/verifyReceipt
iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt
bundleId: ${APPLE_BUNDLE_ID}
serviceId: ${APPLE_SERVICE_ID}
line:
channelId: ${LINE_CHANNEL_ID}

View File

@@ -0,0 +1,40 @@
package kr.co.vividnext.sodalive.member.social.apple
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class AppleIdentityTokenVerifierTest {
@Test
@DisplayName("aud가 bundleId와 일치하면 허용된다")
fun shouldAcceptBundleIdAudience() {
val verifier = AppleIdentityTokenVerifier(bundleId = "kr.co.vividnext.sodalive", serviceId = "com.vividnext.sodalive.web")
assertTrue(verifier.isSupportedAudience(listOf("kr.co.vividnext.sodalive")))
}
@Test
@DisplayName("aud가 serviceId와 일치하면 허용된다")
fun shouldAcceptServiceIdAudience() {
val verifier = AppleIdentityTokenVerifier(bundleId = "kr.co.vividnext.sodalive", serviceId = "com.vividnext.sodalive.web")
assertTrue(verifier.isSupportedAudience(listOf("com.vividnext.sodalive.web")))
}
@Test
@DisplayName("aud가 bundleId와 serviceId 모두 다르면 거부된다")
fun shouldRejectUnknownAudience() {
val verifier = AppleIdentityTokenVerifier(bundleId = "kr.co.vividnext.sodalive", serviceId = "com.vividnext.sodalive.web")
assertFalse(verifier.isSupportedAudience(listOf("com.other.app")))
}
@Test
@DisplayName("bundleId와 serviceId가 모두 비어있으면 거부된다")
fun shouldRejectWhenExpectedAudienceIsMissing() {
val verifier = AppleIdentityTokenVerifier(bundleId = " ", serviceId = "")
assertFalse(verifier.isSupportedAudience(listOf("com.vividnext.sodalive.web")))
}
}