From a4ffab035159d5321f9951304c7b1614bb7e31a3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 30 Mar 2026 09:21:59 +0900 Subject: [PATCH] =?UTF-8?q?fix(member-social):=20=EC=95=A0=ED=94=8C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20aud=20=EA=B2=80=EC=A6=9D=EC=97=90?= =?UTF-8?q?=20serviceId=EB=A5=BC=20=ED=8F=AC=ED=95=A8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260330_애플로그인aud검증실패원인분석.md | 50 +++++++++++++++++++ .../apple/AppleIdentityTokenVerifier.kt | 21 ++++++-- src/main/resources/application.yml | 1 + .../apple/AppleIdentityTokenVerifierTest.kt | 40 +++++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 docs/20260330_애플로그인aud검증실패원인분석.md create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifierTest.kt diff --git a/docs/20260330_애플로그인aud검증실패원인분석.md b/docs/20260330_애플로그인aud검증실패원인분석.md new file mode 100644 index 00000000..f29fb85d --- /dev/null +++ b/docs/20260330_애플로그인aud검증실패원인분석.md @@ -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` → 성공 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifier.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifier.kt index a0df3d66..fc20b439 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifier.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifier.kt @@ -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 = JWKSourceBuilder.create(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): Boolean { + val expectedAudiences = resolveExpectedAudiences() + return expectedAudiences.isNotEmpty() && audience.any { expectedAudiences.contains(it) } + } + + private fun resolveExpectedAudiences(): Set { + 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)) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7a5f9ff3..85d5f0f4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifierTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifierTest.kt new file mode 100644 index 00000000..24cab318 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifierTest.kt @@ -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"))) + } +}