diff --git a/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt b/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt index 52b1d4258..3dd1aa79c 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt @@ -29,9 +29,10 @@ class TokenAuthenticator route: Route?, response: Response, ): Request? { - // 현재 비밀번호 불일치도 HTTP 401이다. 토큰 갱신이나 한도 초과로 세션을 지우지 않고 - // 원래 오류 본문을 Retrofit의 실패 처리에 넘긴다. - if (response.isPasswordMismatch()) return null + // 현재 비밀번호 불일치와 패스키 검증 실패도 HTTP 401이다. 토큰 갱신이나 한도 초과로 세션을 지우지 않고 + // 원래 오류 본문을 Retrofit의 실패 처리에 넘긴다. 패스키 등록은 첫 요청에서 서버가 챌린지를 + // 소비하므로, 재시도하면 실제 사유(2701)가 챌린지 무효(2700)로 바뀐다 (#2287). + if (response.isBusinessUnauthorized()) return null if (response.responseCount >= 3) { errorReporter.recordAuthContractViolation(AUTH_STAGE_RETRY_LIMIT) @@ -81,7 +82,7 @@ class TokenAuthenticator } /** 확인된 업무 오류만 제외한다. 알 수 없거나 파싱할 수 없는 401은 기존 갱신 경로를 따른다. */ -private fun Response.isPasswordMismatch(): Boolean { +private fun Response.isBusinessUnauthorized(): Boolean { val body = peekBody(MAX_ERROR_BODY_BYTES + 1) if (body.contentLength() > MAX_ERROR_BODY_BYTES) return false val parsed = @@ -93,7 +94,7 @@ private fun Response.isPasswordMismatch(): Boolean { null } val code = parsed?.get("code") as? JsonPrimitive - return code != null && !code.isString && code.intOrNull == CODE_PASSWORD_MISMATCH + return code != null && !code.isString && code.intOrNull in BUSINESS_UNAUTHORIZED_CODES } private fun ErrorReporter.recordAuthContractViolation(authStage: String) { @@ -128,4 +129,6 @@ private const val AUTH_STAGE_RETRY_LIMIT = "retry_limit" private const val AUTH_STAGE_MISSING_AUTH_HEADER = "missing_auth_header" private const val AUTH_STAGE_SAME_TOKEN = "same_token" private const val CODE_PASSWORD_MISMATCH = 1202 +private const val CODE_PASSKEY_VERIFICATION_FAILED = 2701 +private val BUSINESS_UNAUTHORIZED_CODES = setOf(CODE_PASSWORD_MISMATCH, CODE_PASSKEY_VERIFICATION_FAILED) private const val MAX_ERROR_BODY_BYTES = 64 * 1024L diff --git a/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt index 95a762598..2b3c2edbe 100644 --- a/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt +++ b/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt @@ -79,20 +79,24 @@ class TokenAuthenticatorTest { } @Test - fun `비밀번호 불일치 1202는 갱신과 세션 정리 없이 원래 본문을 남긴다`() { - listOf(0, 1, 2).forEach { priorCount -> - val reporter = FakeErrorReporter() - val repository = networkFakeAuthRepository(accessToken = "old-token") - val body = """{"status":401,"code":1202,"message":"현재 비밀번호가 일치하지 않습니다."}""" - val response = unauthorizedResponse(priorCount = priorCount, body = body) - - assertNull(authenticator(repository, reporter).authenticate(null, response)) - - assertEquals(0, repository.rotateCallCount) - assertEquals(0, repository.clearSessionCallCount) - assertEquals("old-token", repository.accessToken) - assertEquals(0, reporter.writtenFailures.size) - assertEquals(body, response.body.string()) + fun `비밀번호 불일치 1202와 패스키 검증 실패 2701은 갱신과 세션 정리 없이 원래 본문을 남긴다`() { + listOf( + """{"status":401,"code":1202,"message":"현재 비밀번호가 일치하지 않습니다."}""", + """{"status":401,"code":2701,"message":"패스키 검증에 실패했습니다."}""", + ).forEach { body -> + listOf(0, 1, 2).forEach { priorCount -> + val reporter = FakeErrorReporter() + val repository = networkFakeAuthRepository(accessToken = "old-token") + val response = unauthorizedResponse(priorCount = priorCount, body = body) + + assertNull(body, authenticator(repository, reporter).authenticate(null, response)) + + assertEquals(body, 0, repository.rotateCallCount) + assertEquals(body, 0, repository.clearSessionCallCount) + assertEquals(body, "old-token", repository.accessToken) + assertEquals(body, 0, reporter.writtenFailures.size) + assertEquals(body, response.body.string()) + } } } @@ -100,7 +104,6 @@ class TokenAuthenticatorTest { fun `토큰 거절 1000과 미확인 또는 파싱 불가 401은 기존 갱신 경로를 따른다`() { listOf( """{"status":401,"code":1000}""", - """{"status":401,"code":2701}""", """{"status":401,"code":9999}""", """{"status":401}""", """{"code":"1202"}""",