Skip to content

feat: Apple 로그인 서버 인증 및 토큰 자동 리프레시 구현 - #79

Merged
lluke0 merged 4 commits into
developfrom
feature/#48-apple-login-token-refresh
Mar 30, 2026
Merged

feat: Apple 로그인 서버 인증 및 토큰 자동 리프레시 구현#79
lluke0 merged 4 commits into
developfrom
feature/#48-apple-login-token-refresh

Conversation

@lluke0

@lluke0 lluke0 commented Mar 29, 2026

Copy link
Copy Markdown
Member

요약

Apple 로그인의 서버 인증 연동과 provider 기반 로그인 분기를 구현하고, Ktor Auth 플러그인을 활용한 토큰 자동 첨부 및 리프레시 기능을 추가했습니다. 통합 테스트를 통해 토큰 만료/갱신 시나리오를 검증합니다.

resolve: #48

변경 사항

Features

  • feat: Ktor Auth 플러그인 기반 토큰 자동 첨부/리프레시 및 통합 테스트 구현
  • feat: Apple 로그인 서버 인증 연동 및 provider 기반 로그인 분기 구현

🤖 Generated with Claude Code

Summary by CodeRabbit

  • 새로운 기능

    • Apple 로그인 흐름을 실제 인증 서버와 연동해 동작하도록 개선하고, 인증 만료 시 자동 토큰 갱신 및 관련 이벤트 발행을 추가했습니다.
  • 버그 수정

    • 네트워크 오류 토스트 메시지를 통일했습니다.
    • 소셜 로그인 버튼이 로딩 중 비활성화되도록 수정했습니다.
  • 테스트

    • 토큰 인증/갱신 동작을 검증하는 통합 테스트와 테스트용 로컬 인증 더미를 추가했습니다.

lluke0 and others added 2 commits March 29, 2026 15:13
- HttpClientModule에 Bearer Auth 플러그인 설치 (loadTokens, refreshTokens, sendWithoutRequest)
- 별도 refreshClient로 토큰 갱신 API 호출 (순환 참조 방지)
- AuthEventBus(SharedFlow)로 리프레시 실패 시 인증 만료 이벤트 전파
- MockEngine 기반 통합 테스트 6개 시나리오 (토큰 첨부, 제외 경로, 리프레시+재시도, 실패 처리, 동시 요청)
- ktor-client-auth, ktor-client-mock 의존성 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- signIn API에 provider 파라미터 추가하여 APPLE/KAKAO 로그인 구분
- AppleLoginUseCase에 서버 인증 요청 및 토큰 저장 로직 추가
- iOS Apple Sign-In에 nonce 생성(UUID) 및 SHA256 해시 처리 적용
- AppleSignInResult에 nonce 필드 추가
- LandingViewModel에서 실제 Apple 로그인 플로우 연결
- Apple 로그인 버튼에 로딩 상태 반영

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Mar 29, 2026

Copy link
Copy Markdown

Walkthrough

Apple/Kakao 로그인에 provider/nonce 전달을 추가하고, HTTP 클라이언트에 토큰 자동 갱신 및 AuthEventBus를 도입했으며, iOS nonce 생성과 토큰 인증 통합 테스트 및 관련 DI/뷰모델·UI 변경을 추가했습니다.

Changes

Cohort / File(s) Summary
빌드 설정
gradle/libs.versions.toml, composeApp/build.gradle.kts
Ktor client-authclient-mock 항목을 버전 카탈로그에 추가하고 commonMain/commonTest에 의존성 등록.
도메인 & 유스케이스
composeApp/src/commonMain/kotlin/.../domain/auth/repository/AuthRepository.kt, .../usecase/AppleLoginUseCase.kt, .../usecase/KakaoLoginUseCase.kt, .../di/DomainModules.kt
AuthRepository.signIn에 provider 인자 추가. AppleLoginUseCase가 AuthRepository 의존성 추가, 반환 타입을 SignInResult로 변경하고 저장 흐름 호출로 전환. DI 등록 인자 변경 및 Kakao 호출에 provider 추가.
저장소/원격 레이어
composeApp/src/commonMain/kotlin/.../data/auth/repository/AuthRepositoryImpl.kt, .../datasource/RemoteAuthDataSource.kt, .../remote/auth/RemoteAuthDataSourceImpl.kt, .../remote/auth/model/SignInRequest.kt
signIn 시 provider 파라미터를 수용하도록 인터페이스/구현체 시그니처 변경 및 SignInRequest에 provider 필드 추가; 요청 페이로드에 provider 포함.
HTTP 클라이언트 & 인증 관리
composeApp/src/commonMain/kotlin/.../remote/di/HttpClientModule.kt
환경별 BASE_URL 도입, AUTH_EXCLUDED_PATHS 추가, AuthEventBus 등록 및 별도 refreshClient 구성. Bearer Auth 플러그인으로 액세스 토큰 자동 첨부·갱신, 토큰 저장/초기화, 실패 시 이벤트 발행 및 제외 경로 처리 구현.
인증 이벤트 버스
composeApp/src/commonMain/kotlin/.../remote/auth/AuthEventBus.kt
AuthEventBus 및 AuthEvent.TokenExpired 추가(SharedFlow 기반, tryEmit).
테스트
composeApp/src/commonTest/kotlin/.../remote/auth/FakeLocalAuthDataSource.kt, .../TokenAuthIntegrationTest.kt
FakeLocalAuthDataSource 추가 및 MockEngine 기반의 토큰 갱신/헤더 첨부/동시성 동작을 검증하는 통합 테스트 추가.
플랫폼(iOS) Apple 로그인
composeApp/src/iosMain/kotlin/.../AppleSignInClient.ios.kt, composeApp/src/commonMain/kotlin/.../AppleSignInClient.kt
iOS에서 rawNonce 생성 및 sha256 해시를 request.nonce에 할당하고, AppleSignInResult에 raw nonce 포함.
UI / ViewModel
composeApp/src/commonMain/kotlin/.../presentation/landing/LandingScreen.kt, .../LandingViewModel.kt, composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/MainViewModel.kt
Apple 소셜 버튼이 로딩 중 비활성화되도록 변경. LandingViewModel에 AppleLoginUseCase 주입 및 비동기 로그인 흐름(로딩 방지, 성공→NavigateHome, 실패→HandleException) 추가. MainViewModel의 예외 처리 시 토스트 메시지를 고정 한국어 문자열로 변경.

Sequence Diagram(s)

sequenceDiagram
    participant User as "User"
    participant UI as "LandingScreen"
    participant VM as "LandingViewModel"
    participant UseCase as "AppleLoginUseCase"
    participant AppleClient as "AppleSignInClient"
    participant Repo as "AuthRepository"
    participant Remote as "RemoteAuthDataSource"
    participant HTTP as "HttpClient"
    participant Refresh as "refreshClient"
    participant Server as "Backend API"
    participant Bus as "AuthEventBus"

    User->>UI: Apple 로그인 클릭
    UI->>VM: onAppleLogin()
    VM->>VM: isLoading = true
    VM->>UseCase: invoke()
    UseCase->>AppleClient: signIn() (identityToken, nonce)
    AppleClient-->>UseCase: AppleSignInResult(identityToken, nonce)
    UseCase->>Repo: signIn(provider="APPLE", idToken, nonce)
    Repo->>Remote: signIn(provider, idToken, nonce)
    Remote->>HTTP: POST users/signin (payload 포함)
    HTTP->>Server: 요청 (Bearer 헤더 자동 첨부 가능)
    alt 200
        Server-->>HTTP: accessToken, refreshToken
        HTTP->>Repo: 응답 전달
    else 401
        HTTP->>Refresh: POST auth/refresh (refreshToken)
        Refresh->>Server: refresh 요청
        alt refresh success
            Server-->>Refresh: 새 accessToken, refreshToken
            Refresh->>HTTP: 반환된 토큰 전달
            HTTP->>Repo: 토큰 저장 및 원래 요청 재시도
        else refresh fail
            HTTP->>Bus: emit(TokenExpired)
            HTTP->>Repo: clearTokens
        end
    end
    Repo-->>UseCase: SignInResult
    UseCase-->>VM: Success -> NavigateHome
    VM->>VM: isLoading = false
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 분

Possibly related PRs

Suggested reviewers

  • kimmandoo
  • BEEEAM-J

🐰 새 nonce를 품고 달려가네,
토큰은 다시 빛나고 길은 열리네.
Provider도 손을 잡아 로그인 춤,
버스가 울리면 집으로 달려간다. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Pull request title clearly summarizes the main changes: Apple login server authentication integration and token auto-refresh implementation.
Description check ✅ Passed PR description covers main changes and resolves issue #48, but lacks detailed breakdown of specific implementation points and missing screenshot.
Linked Issues check ✅ Passed Code changes fully implement Apple login server authentication [모든 파일], provider-based login branching [AppleLoginUseCase, KakaoLoginUseCase, RemoteAuthDataSource], and token auto-refresh mechanism [HttpClientModule, TokenAuthIntegrationTest], aligning with landing page feature requirements.
Out of Scope Changes check ✅ Passed All changes directly support the landing page login flow: Apple/Kakao provider implementation, token refresh, bearer auth config, and exception handling. No unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/#48-apple-login-token-refresh

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@lluke0
lluke0 requested a review from BEEEAM-J March 29, 2026 13:31
@lluke0 lluke0 self-assigned this Mar 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/MainViewModel.kt (1)

11-11: ⚠️ Potential issue | 🟡 Minor

미사용 import 제거 필요

UnknownException은 파일에서 참조되지 않으므로 import를 제거하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/MainViewModel.kt`
at line 11, Remove the unused import of UnknownException from the top of
MainViewModel (delete the line importing
com.chukchukhaksa.mobile.common.model.UnknownException) so there are no unused
imports; if the exception is intended to be used, instead reference
UnknownException where needed (e.g., in error handling methods) or add the
correct usage, otherwise simply remove the import line.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/repository/AuthRepository.kt`:
- Around line 5-6: Change the provider parameter from a free-form String to a
type-safe enum/sealed type and update usages: replace AuthRepository.suspend fun
signIn(provider: String, idToken: String, nonce: String): SignInResult with a
version that accepts an AuthProvider (or sealed class) and create the
AuthProvider enum (e.g., APPLE, KAKAO) so callers use AuthProvider.KAKAO; then
update all call sites of AuthRepository.signIn to pass the new enum value and
adjust any serialization/mapping code where provider strings are
produced/consumed.

In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/usecase/AppleLoginUseCase.kt`:
- Line 15: Extract the hard-coded provider string into a shared constant: create
an object (e.g., AuthProvider) with const val APPLE = "APPLE" and KAKAO =
"KAKAO", then replace provider = "APPLE" in AppleLoginUseCase and provider =
"KAKAO" in KakaoLoginUseCase to use AuthProvider.APPLE and AuthProvider.KAKAO
respectively to ensure consistency and avoid magic strings.

In `@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/MainViewModel.kt`:
- Around line 69-71: Current code always shows "네트워크 오류가 발생했습니다." then calls
throwable.record(); update the error handling in MainViewModel (the block that
calls onShowToast and throwable.record()) to inspect the exception type (e.g.,
check for NetworkException) and choose a message accordingly — show a specific
network message only for NetworkException, map other known exceptions
(auth/validation) to appropriate localized messages, and otherwise show a
generic fallback like "오류가 발생했습니다."; still call throwable.record() after
deciding the message.

In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/AuthEventBus.kt`:
- Around line 7-13: AuthEventBus currently constructs _events as
MutableSharedFlow<AuthEvent>(extraBufferCapacity = 1) so events emitted when
there are no subscribers are lost; update the MutableSharedFlow construction in
class AuthEventBus (the _events property) to include replay = 1 (e.g.,
MutableSharedFlow<AuthEvent>(replay = 1, extraBufferCapacity = 1)) so that late
subscribers to events (AuthEventBus.events) will receive the last emitted event;
leave the emit() method (tryEmit or semantics) unchanged.

In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt`:
- Around line 104-110: The code calls localAuthDataSource.clearTokens() when
currentRefreshToken == null inside refreshTokens, which will also remove the
access token and can be unexpected; change this to only clear the refresh token
(e.g., call a clearRefreshToken()/removeRefreshToken() method) or remove the
clearTokens() call if you want to preserve the access token, but keep
authEventBus.emit() and the return@refreshTokens null behavior in the
refreshTokens block so the missing-refresh-token case is handled without
deleting unrelated tokens.
- Around line 61-74: The refreshClient HttpClient is missing timeout
configuration so refresh token requests may hang; add the same HttpTimeout
install used in the main client to refreshClient (install(HttpTimeout) with
appropriate connect/request/socket timeouts) and ensure it is configured
alongside the existing ContentNegotiation and defaultRequest settings on the
refreshClient declaration so refresh calls use the same timeout behavior as the
main HttpClient.

In
`@composeApp/src/commonTest/kotlin/com/chukchukhaksa/mobile/remote/auth/TokenAuthIntegrationTest.kt`:
- Around line 49-52: TokenAuthIntegrationTest.kt defines AUTH_EXCLUDED_PATHS
that duplicates the list in HttpClientModule; remove the duplicated list from
the test and instead reference the single source of truth by importing or
accessing the constant from HttpClientModule (or extract a new shared constant
in a common test/production constants object and use that in both places) so the
test stays in sync with production paths.
- Around line 306-344: The test `동시 요청 시 리프레시는 1회만 수행된다` relies on unreliable
Ktor behavior; update the test to either (A) enforce synchronization inside the
refresh flow by making the client’s `refreshTokens` callback use a Mutex (so
concurrent 401s will queue and only one actual refresh HTTP call is made —
reference the function/callback used when building the client in
`buildTestClient` and the refresh engine `refreshEngine`), or (B) relax the
assertion to not require exactly one refresh and instead assert functional
outcomes only (e.g., that all requests eventually succeed and that
`FakeLocalAuthDataSource`’s tokens were updated to
`"new-access-token"`/`"new-refresh-token"`), removing the brittle
`assertEquals(1, refreshLog.size)` check; pick one approach and apply it to the
test and any refresh-callback implementation used by `buildTestClient`.

In
`@composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/AppleSignInClient.ios.kt`:
- Around line 35-36: Replace the ad-hoc sha256Hex(rawNonce) call in
AppleSignInClient.ios.kt with a call to a vetted crypto library: add either
org.kotlincrypto.hash:sha2 (pure Kotlin KMP) or cryptography-kotlin (delegates
to CryptoKit on iOS), implement a small wrapper function (e.g., computeSha256Hex
or reuse sha256Hex name) that uses the library API to produce the hex digest
from rawNonce, update imports and platform-specific implementation in
AppleSignInClient (and tests) to call that wrapper instead of the custom
implementation, and ensure error handling and test coverage remain intact.

---

Outside diff comments:
In `@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/MainViewModel.kt`:
- Line 11: Remove the unused import of UnknownException from the top of
MainViewModel (delete the line importing
com.chukchukhaksa.mobile.common.model.UnknownException) so there are no unused
imports; if the exception is intended to be used, instead reference
UnknownException where needed (e.g., in error handling methods) or add the
correct usage, otherwise simply remove the import line.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 21d636f2-2ccf-474d-8cda-c6f4395374ec

📥 Commits

Reviewing files that changed from the base of the PR and between 65f4761 and 0e52ad4.

📒 Files selected for processing (19)
  • composeApp/build.gradle.kts
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/MainViewModel.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/AppleSignInClient.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/datasource/RemoteAuthDataSource.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/auth/repository/AuthRepositoryImpl.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/repository/AuthRepository.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/usecase/AppleLoginUseCase.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/usecase/KakaoLoginUseCase.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/landing/LandingScreen.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/landing/LandingViewModel.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/AuthEventBus.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/RemoteAuthDataSourceImpl.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/auth/model/SignInRequest.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt
  • composeApp/src/commonTest/kotlin/com/chukchukhaksa/mobile/remote/auth/FakeLocalAuthDataSource.kt
  • composeApp/src/commonTest/kotlin/com/chukchukhaksa/mobile/remote/auth/TokenAuthIntegrationTest.kt
  • composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/AppleSignInClient.ios.kt
  • gradle/libs.versions.toml

- refreshClient에 timeout 설정이 없어 네트워크 문제 시 무한 대기 가능한 문제 수정
- 테스트에서 AUTH_EXCLUDED_PATHS를 중복 정의하지 않고 프로덕션 상수를 참조하도록 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt (1)

109-130: ⚠️ Potential issue | 🟠 Major

refreshTokens는 직렬화하고 CancellationException은 재던지세요.

Ktor 공식 API는 refreshTokens를 401 수신 시 실행되는 콜백으로만 설명하고, 3.1 changelog에도 401 응답이 이 콜백으로 올바르게 큐잉되지 않는 문제가 언급돼 있습니다. 지금처럼 별도 동기화 없이 refresh를 수행하고 catch (Exception)로 전부 만료 처리하면, 동시 만료 요청에서 refresh token을 회전시키는 서버와 경합할 수 있고 취소/timeout에도 clearTokens() + emit()로 사용자를 로그아웃시킬 수 있습니다. Mutex로 refresh를 1회만 수행하고, CancellationException은 재던지며, 토큰 삭제는 서버가 refresh token 무효를 명시한 경우로 한정해 주세요. (api.ktor.io)

Ktor client Bearer Auth의 `refreshTokens`는 동시 401 응답을 1회의 refresh로 보장하나요? 또 Kotlin coroutine 문서는 `catch (Exception)`에서 `CancellationException`을 재던지라고 안내하나요?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt`
around lines 109 - 130, The refreshTokens block currently catches all
Exceptions, clears tokens and emits logout, and may run multiple concurrent
refreshes; change it to use a coroutine-safe single-flight Mutex (e.g.,
tokenRefreshMutex) to ensure only one refreshClient.post(...) runs at a time,
rethrow CancellationException immediately, and only call
localAuthDataSource.clearTokens() + authEventBus.emit() when the refresh
response explicitly indicates the refresh token is invalid/expired (e.g., check
response status or API error code), otherwise propagate or return null without
wiping local tokens; keep refreshClient.post(...) and
localAuthDataSource.saveAccessToken/saveRefreshToken as before but perform these
updates inside the mutex-protected successful-path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt`:
- Around line 134-138: 현재 sendWithoutRequest 블록에서 requestPath를
AUTH_EXCLUDED_PATHS와 부분 문자열 비교(contains)하여 오탐이 발생합니다;
request.url.pathSegments.joinToString("/")로 만든 상대 경로를 정규화(불필요한 앞/뒤 슬래시 제거 및 URL
디코딩 등)한 뒤 AUTH_EXCLUDED_PATHS의 각 항목과 정확한 문자열 비교(==)로 검사하도록 바꾸세요; 대상 식별자는
sendWithoutRequest, requestPath, AUTH_EXCLUDED_PATHS 입니다.

In
`@composeApp/src/commonTest/kotlin/com/chukchukhaksa/mobile/remote/auth/TokenAuthIntegrationTest.kt`:
- Around line 79-146: The test reimplements production auth logic in
buildTestClient; extract the shared HttpClient builder from HttpClientModule
into a reusable function (e.g., createAuthHttpClient or HttpClientFactory) that
accepts dependencies like a LocalAuthDataSource, AuthEventBus and an Engine,
then update both HttpClientModule and the test to call that shared builder
instead of duplicating loadTokens, refreshTokens and sendWithoutRequest logic;
ensure the new factory wires the same loadTokens, refreshTokens and
sendWithoutRequest implementations used in production so tests exercise the real
auth behavior.

---

Duplicate comments:
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt`:
- Around line 109-130: The refreshTokens block currently catches all Exceptions,
clears tokens and emits logout, and may run multiple concurrent refreshes;
change it to use a coroutine-safe single-flight Mutex (e.g., tokenRefreshMutex)
to ensure only one refreshClient.post(...) runs at a time, rethrow
CancellationException immediately, and only call
localAuthDataSource.clearTokens() + authEventBus.emit() when the refresh
response explicitly indicates the refresh token is invalid/expired (e.g., check
response status or API error code), otherwise propagate or return null without
wiping local tokens; keep refreshClient.post(...) and
localAuthDataSource.saveAccessToken/saveRefreshToken as before but perform these
updates inside the mutex-protected successful-path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c1a99b62-57f2-4027-b98e-8c80f35f0bb7

📥 Commits

Reviewing files that changed from the base of the PR and between 0e52ad4 and fd33ee8.

📒 Files selected for processing (2)
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt
  • composeApp/src/commonTest/kotlin/com/chukchukhaksa/mobile/remote/auth/TokenAuthIntegrationTest.kt

Comment on lines +134 to +138
sendWithoutRequest { request ->
val requestPath = request.url.pathSegments.joinToString("/")
AUTH_EXCLUDED_PATHS.none { path ->
requestPath.contains(path)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

인증 제외 경로는 부분 문자열이 아니라 정확히 비교하세요.

contains()라서 users/signin-extraauth/refresh-preview 같은 경로도 제외 대상으로 처리됩니다. 상대 경로를 정규화한 뒤 AUTH_EXCLUDED_PATHS와 정확히 비교하는 편이 안전합니다.

🛠️ 제안
                     sendWithoutRequest { request ->
-                        val requestPath = request.url.pathSegments.joinToString("/")
-                        AUTH_EXCLUDED_PATHS.none { path ->
-                            requestPath.contains(path)
-                        }
+                        val requestPath = request.url.pathSegments
+                            .filter { it.isNotBlank() }
+                            .dropWhile { it == "api" }
+                            .joinToString("/")
+                        requestPath !in AUTH_EXCLUDED_PATHS
                     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt`
around lines 134 - 138, 현재 sendWithoutRequest 블록에서 requestPath를
AUTH_EXCLUDED_PATHS와 부분 문자열 비교(contains)하여 오탐이 발생합니다;
request.url.pathSegments.joinToString("/")로 만든 상대 경로를 정규화(불필요한 앞/뒤 슬래시 제거 및 URL
디코딩 등)한 뒤 AUTH_EXCLUDED_PATHS의 각 항목과 정확한 문자열 비교(==)로 검사하도록 바꾸세요; 대상 식별자는
sendWithoutRequest, requestPath, AUTH_EXCLUDED_PATHS 입니다.

AuthConfig.configureBearerAuth() 확장 함수로 loadTokens, refreshTokens,
sendWithoutRequest 로직을 추출하고, HttpClientModule과
TokenAuthIntegrationTest 양쪽에서 공유하도록 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt (2)

96-100: ⚠️ Potential issue | 🟡 Minor

인증 제외 경로는 부분 문자열이 아니라 정확 비교로 검사하세요.

지금처럼 contains()를 쓰면 users/signin-extraauth/refresh-preview 같은 경로도 제외 대상으로 오인됩니다. 경로를 정규화한 뒤 AUTH_EXCLUDED_PATHS와 정확히 비교해야 의도한 엔드포인트만 우회됩니다.

🛠️ 제안
         sendWithoutRequest { request ->
-            val requestPath = request.url.pathSegments.joinToString("/")
-            AUTH_EXCLUDED_PATHS.none { path ->
-                requestPath.contains(path)
-            }
+            val requestPath = request.url.pathSegments
+                .filter { it.isNotBlank() }
+                .joinToString("/")
+                .removePrefix("api/")
+            requestPath !in AUTH_EXCLUDED_PATHS
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt`
around lines 96 - 100, 현재 sendWithoutRequest 블록에서 requestPath를
AUTH_EXCLUDED_PATHS와 contains()로 부분 문자열 검사하고 있어 의도치 않은 매칭이 발생합니다; requestPath를
정규화(중복/선행/후행 슬래시 제거, URL 디코딩 등)한 뒤 AUTH_EXCLUDED_PATHS의 각 항목과 정확(equal) 비교하도록
변경하세요 (즉 sendWithoutRequest 내부의 requestPath 계산과 AUTH_EXCLUDED_PATHS.none { ... }
로직을 수정하여 contains 대신 == 또는 정확한 경로 매칭 로직을 사용하고 필요하면 세그먼트 단위 비교를 적용).

71-87: ⚠️ Potential issue | 🟠 Major

동시 401에서 refresh 경합을 막는 직렬화가 없습니다.

현재 구현은 refresh 구간을 로컬에서 보호하지 않아서, bearer 플러그인이 동시 401에 대해 refreshTokens를 여러 번 호출하는 버전/상황에서는 같은 refresh token으로 중복 갱신이 발생할 수 있습니다. 토큰 회전 서버라면 뒤늦은 refresh 실패가 방금 저장한 새 토큰 상태를 다시 만료 처리하는 경로로 이어질 수 있으니, single-refresh가 요구사항이면 Mutex로 이 블록을 serialize하고 잠금 획득 후 저장된 토큰을 재확인하는 편이 안전합니다.

이 저장소가 사용하는 Ktor client 버전에서 bearer Auth 플러그인의 `refreshTokens {}` 는 동시에 여러 요청이 `401 Unauthorized`를 받았을 때 refresh 호출을 1회로 직렬화해 주나요, 아니면 애플리케이션이 `Mutex` 등으로 직접 보호해야 하나요?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt`
around lines 71 - 87, Wrap the refresh flow in a Mutex to serialize concurrent
refreshes: introduce a shared Mutex (e.g., refreshMutex) and inside the
refreshTokens { } handler call refreshMutex.withLock { ... }; after acquiring
the lock re-read the stored refresh token via
localAuthDataSource.getRefreshToken() to detect if another coroutine already
refreshed and return the current tokens if so, otherwise perform the refresh
POST (the existing refreshClient.post("auth/refresh") /
response.getDataOrThrow()), then save with
localAuthDataSource.saveAccessToken(...) and saveRefreshToken(...), and return
the new BearerTokens(result.accessToken, result.refreshToken); ensure the branch
that handles null tokens still clears tokens and emits authEventBus.emit()
before returning null.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt`:
- Around line 88-92: The catch block in HttpClientModule.kt currently treats any
Exception as a guaranteed token-invalidation case and calls
localAuthDataSource.clearTokens() and authEventBus.emit(), which disconnects
users on transient failures; change the handler in the refresh flow (the catch
around the refresh call) to only clear tokens and emit logout when the exception
is a server response that definitively indicates an invalid/expired refresh
token (e.g., inspect ResponseException / HttpResponseException and check
response.status is the specific status the backend uses for invalid refresh
tokens like 401/403 or a dedicated error code), otherwise preserve tokens and
propagate or return a failed refresh result without calling
localAuthDataSource.clearTokens() or authEventBus.emit().

---

Duplicate comments:
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt`:
- Around line 96-100: 현재 sendWithoutRequest 블록에서 requestPath를
AUTH_EXCLUDED_PATHS와 contains()로 부분 문자열 검사하고 있어 의도치 않은 매칭이 발생합니다; requestPath를
정규화(중복/선행/후행 슬래시 제거, URL 디코딩 등)한 뒤 AUTH_EXCLUDED_PATHS의 각 항목과 정확(equal) 비교하도록
변경하세요 (즉 sendWithoutRequest 내부의 requestPath 계산과 AUTH_EXCLUDED_PATHS.none { ... }
로직을 수정하여 contains 대신 == 또는 정확한 경로 매칭 로직을 사용하고 필요하면 세그먼트 단위 비교를 적용).
- Around line 71-87: Wrap the refresh flow in a Mutex to serialize concurrent
refreshes: introduce a shared Mutex (e.g., refreshMutex) and inside the
refreshTokens { } handler call refreshMutex.withLock { ... }; after acquiring
the lock re-read the stored refresh token via
localAuthDataSource.getRefreshToken() to detect if another coroutine already
refreshed and return the current tokens if so, otherwise perform the refresh
POST (the existing refreshClient.post("auth/refresh") /
response.getDataOrThrow()), then save with
localAuthDataSource.saveAccessToken(...) and saveRefreshToken(...), and return
the new BearerTokens(result.accessToken, result.refreshToken); ensure the branch
that handles null tokens still clears tokens and emits authEventBus.emit()
before returning null.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ff69406a-2d18-499a-876b-15ca1db33d5a

📥 Commits

Reviewing files that changed from the base of the PR and between fd33ee8 and 8fdc640.

📒 Files selected for processing (2)
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt
  • composeApp/src/commonTest/kotlin/com/chukchukhaksa/mobile/remote/auth/TokenAuthIntegrationTest.kt

Comment on lines +88 to +92
} catch (e: Exception) {
Napier.e("Token refresh failed", e)
localAuthDataSource.clearTokens()
authEventBus.emit()
null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

일시적 refresh 실패까지 로그아웃 처리하면 세션이 불필요하게 끊깁니다.

여기서는 catch (Exception)이 타임아웃, 네트워크 단절, 5xx, 역직렬화 오류까지 모두 clearTokens()/emit()로 처리합니다. 이 경우 refresh token이 아직 유효해도 사용자를 강제로 로그아웃시킬 수 있으니, 토큰 삭제는 서버가 refresh token 무효를 확정한 응답에서만 수행하고 그 외 실패는 토큰을 보존한 채 요청만 실패시키는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt`
around lines 88 - 92, The catch block in HttpClientModule.kt currently treats
any Exception as a guaranteed token-invalidation case and calls
localAuthDataSource.clearTokens() and authEventBus.emit(), which disconnects
users on transient failures; change the handler in the refresh flow (the catch
around the refresh call) to only clear tokens and emit logout when the exception
is a server response that definitively indicates an invalid/expired refresh
token (e.g., inspect ResponseException / HttpResponseException and check
response.status is the specific status the backend uses for invalid refresh
tokens like 401/403 or a dedicated error code), otherwise preserve tokens and
propagate or return a failed refresh result without calling
localAuthDataSource.clearTokens() or authEventBus.emit().

@lluke0
lluke0 merged commit 45327c5 into develop Mar 30, 2026
1 of 2 checks passed
@lluke0
lluke0 deleted the feature/#48-apple-login-token-refresh branch March 30, 2026 12:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] 랜딩페이지

2 participants