Skip to content

fix: 앱 로그인 풀림 현상 해결 - #371

Merged
iOdiO89 merged 9 commits into
devfrom
fix/367-app-login
Jul 23, 2026
Merged

fix: 앱 로그인 풀림 현상 해결#371
iOdiO89 merged 9 commits into
devfrom
fix/367-app-login

Conversation

@iOdiO89

@iOdiO89 iOdiO89 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

작업 요약

  • 앱(WebView) 로그인이 유지되지 않고 풀리던 문제 해결
  • 근본 원인: 토큰 갱신 응답을 snake_case로 잘못 파싱 → 새 토큰 저장 실패 → 옛 토큰 재사용으로 서버가 세션 무효화
  • iOS WKWebView 쿠키 저장 특성으로 인한 쿠키 동기화 타이밍 문제 동반
  • 파싱 camelCase 수정 + 웹뷰 워밍업 후 SecureStore ↔ 쿠키 양방향 동기화로 정리
  • 웹 환경은 영향 없음 (토큰을 Set-Cookie로 받아 파싱 경로를 타지 않음)

배경

  • 앱은 토큰이 SecureStoreWebView 쿠키 두 곳에 존재, rotation은 여러 경로(앱 부팅 / 웹 axios·SSE / Next proxy / 공유 익스텐션)에서 발생
  • 서버는 refresh rotation + grace(10초) + 재사용 감지 시 family invalidation 정책 → 오래되거나 잘못된 토큰 제시 시 유효 세션까지 무효화

작업 내용

1. 토큰 갱신 응답 body를 camelCase로 파싱

  • 응답 body는 { accessToken, refreshToken }인데 여러 곳에서 access_token(snake_case)으로 파싱 → 새 토큰을 undefined로 받아 저장 실패
  • 대상: useWebviewCookieSync, postWishLinkFromShare, proxy, refreshClientToken, postTokenRefresh

2. 웹뷰 워밍업 후 쿠키 동기화 + 양방향 동기화 통합

  • iOS WKHTTPCookieStore는 살아있는 WKWebView 없이 쿠키 읽기/쓰기가 유실 → 기존엔 웹뷰 렌더 전 동기화로 시딩 누락
  • 웹뷰를 빈 HTML로 먼저 마운트(워밍업)하고, 로드 완료 후에만 쿠키 동기화 시작
  • sharedCookiesEnabled + iOS 쿠키 이중 저장(WKHTTPCookieStore / NSHTTPCookieStorage)
  • 부팅(SecureStore→쿠키)·복귀(쿠키→SecureStore) 양방향 동기화를 useWebviewCookieSync로 통합
  • 동기화 실패 시에도 웹뷰 렌더(무한 스플래시 방지)

3. JWT 유틸 @piki/core 이전 + iat 기반 최신 토큰 판별

  • packages/core/src/utils/jwt.ts 신규 — decodeJwtPayload / isTokenValid / isFresherToken / getTokenExpiresIso / getTokenMaxAge
  • 두 저장소 중 iat가 최신인 토큰만 채택 → 오래된 사본이 최신 토큰을 덮어쓰지 않도록
  • access 유효 시 부팅 refresh 생략(불필요한 rotation 방지)

4. proxy 세션 쿠키 → 만료 시간 지정

  • app 분기 쿠키에 Max-Age가 없어 앱 종료 시 증발 → 토큰 exp 기반 Max-Age 부여

5. 토큰 갱신 응답 타입 AuthTokensT 추출

  • 응답 구조를 여러 곳에서 반복 선언한 것이 파싱 버그 배경 → 재발 방지용 공용 타입 추출
  • packages/core/src/types/auth.ts 신규 — AuthTokensT = { accessToken: string | null; refreshToken: string | null }
  • postTokenRefresh, refreshClientToken, proxy(반환 타입 경유) 적용

연관 이슈

closes #367

Summary by CodeRabbit

  • 개선 사항
    • 앱 실행 시 웹 콘텐츠가 안정적으로 준비된 후 화면이 표시되도록 초기 로딩 흐름을 개선했습니다.
    • 초기 로딩 중에도 딥링크가 정상적으로 처리됩니다.
    • 앱과 웹 간 로그인 세션 및 인증 토큰 동기화를 강화했습니다.
    • 백그라운드 복귀 후에도 최신 로그인 상태가 유지됩니다.
    • 토큰 만료 시간이 쿠키에 반영되어 인증 상태가 더 안정적으로 유지됩니다.
    • 최신 토큰만 저장하도록 개선해 예기치 않은 로그아웃을 줄였습니다.
    • 토큰 갱신 후 위시 등록 재시도가 정상적으로 처리됩니다.

@iOdiO89 iOdiO89 self-assigned this Jul 23, 2026
@iOdiO89 iOdiO89 linked an issue Jul 23, 2026 that may be closed by this pull request
6 tasks
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
piki Ready Ready Preview, Comment Jul 23, 2026 7:04am

@github-actions
github-actions Bot requested review from kanghaeun and ychany July 23, 2026 04:30
@github-actions

Copy link
Copy Markdown

Discord 스레드 연동용 메타데이터입니다. discord-pr-bot 워크플로가 자동 생성하며, 수정·삭제하면 PR 과 Discord 알림 연동이 끊깁니다.

@github-actions github-actions Bot added the fix Something isn't working label Jul 23, 2026
@iOdiO89 iOdiO89 changed the title fix: 앱 로그인 풀림 — 원인 정리 및 수정 작업 fix: 앱 로그인 풀림 현상 해결 Jul 23, 2026
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@iOdiO89, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 58 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c3b4fede-e8ce-44db-8182-28190a961635

📥 Commits

Reviewing files that changed from the base of the PR and between 489c018 and 6148805.

📒 Files selected for processing (6)
  • apps/web/src/apis/postTokenRefresh.ts
  • apps/web/src/app/archive/wish/layout.tsx
  • apps/web/src/utils/refreshClientToken.ts
  • packages/core/src/index.ts
  • packages/core/src/types/auth.ts
  • packages/core/src/utils/jwt.ts
📝 Walkthrough

Walkthrough

JWT 처리를 packages/core로 공통화하고, refresh 응답 필드를 camelCase로 정렬했다. 앱은 WebView 워밍업 후 양방향 쿠키 동기화를 수행하며, 웹 프록시는 JWT 만료 기반 쿠키 수명을 설정한다.

Changes

인증 토큰 동기화

Layer / File(s) Summary
공통 JWT 유틸리티
packages/core/src/utils/jwt.ts, packages/core/src/index.ts
JWT 페이로드 디코딩, 유효성·최신성 판별, 만료 시각과 Max-Age 계산 함수를 추가하고 재-export한다.
토큰 응답 및 저장 계약 정렬
apps/web/src/apis/postTokenRefresh.ts, apps/web/src/utils/refreshClientToken.ts, apps/app/utils/postWishLinkFromShare.ts, apps/app/utils/tokenStorage.ts, apps/web/src/utils/auth.ts, apps/web/src/hooks/useFcmTokenSync.ts
refresh 토큰 필드를 camelCase와 nullable 형식으로 변경하고, 더 최신인 refresh 토큰만 저장하도록 갱신한다.
앱 WebView 워밍업 및 쿠키 동기화
apps/app/app/index.tsx, apps/app/hooks/useWebviewCookieSync.ts
WebView 워밍업 완료 후 SecureStore와 쿠키를 양방향 동기화하고, 토큰 갱신·401 정리·AppState 동기화를 처리한다.
웹 프록시 쿠키 수명 처리
apps/web/src/proxy.ts
앱용 로그인 및 refresh 쿠키에 JWT 만료 기반 Max-Age를 조건부로 추가하고 camelCase refresh 응답을 처리한다.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Page
  participant WebView
  participant CookieSync
  participant SecureStore
  Page->>WebView: about:blank 워밍업 로드
  WebView-->>Page: onLoadEnd
  Page->>CookieSync: WebView 준비 완료 전달
  CookieSync->>WebView: 쿠키 조회 및 설정
  CookieSync->>SecureStore: 최신 토큰 저장
  Page->>WebView: APP_REQ_NAVIGATE 전달
Loading

Possibly related PRs

Suggested labels: refactor

Suggested reviewers: ychany, kanghaeun

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목이 앱 WebView 로그인 풀림 문제와 핵심 수정 방향을 간결하게 잘 요약합니다.
Linked Issues check ✅ Passed iat 기반 최신성 비교, SecureStore/WebView 양방향 동기화, AppState 재동기화, Max-Age 적용 등 핵심 요구사항이 반영되었습니다.
Out of Scope Changes check ✅ Passed 토큰 동기화·응답 파싱·쿠키 수명 정리 범위로 보이며, 명백한 무관 변경은 보이지 않습니다.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/367-app-login

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.

@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: 4

🧹 Nitpick comments (3)
apps/web/src/apis/postTokenRefresh.ts (1)

6-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

응답 타입을 이름 있는 ResponseT로 추출 권장.

ApiResponseT<{ accessToken: string | null; refreshToken: string | null }>가 인라인으로 선언되어 있습니다. 동일 shape가 proxy.ts에서도 별도로 가정되고 있어(그래프 컨텍스트: postTokenRefreshServer 소비부), 추후 필드가 바뀔 때 두 곳을 동기화해야 하는 드리프트 위험이 있습니다. PostTokenRefreshServerResponseT 같은 이름으로 추출해 재사용하는 것을 권장합니다.

As per coding guidelines, "Name API request and response types as {PascalCaseFunctionName}RequestT and {PascalCaseFunctionName}ResponseT."

♻️ 제안 diff
+export type PostTokenRefreshServerResponseT = ApiResponseT<{
+  accessToken: string | null;
+  refreshToken: string | null;
+}>;
+
 export const postTokenRefreshServer = async (cookies: string) => {
-  const response = await serverApi.post<
-    ApiResponseT<{ accessToken: string | null; refreshToken: string | null }>
-  >(ENDPOINTS.AUTH_TOKEN_REFRESH, void 0, {
+  const response = await serverApi.post<PostTokenRefreshServerResponseT>(
+    ENDPOINTS.AUTH_TOKEN_REFRESH,
+    void 0,
+    {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/apis/postTokenRefresh.ts` around lines 6 - 15, Extract the
inline token-refresh payload type used by postTokenRefreshServer into a named
PostTokenRefreshServerResponseT type following the API naming convention, and
use that type in the serverApi.post generic while preserving the existing
nullable accessToken and refreshToken fields.

Source: Coding guidelines

apps/app/utils/tokenStorage.ts (1)

15-21: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

동시 호출 시 TOCTOU 레이스 가능성.

setTokens의 최신성 검사는 read-then-write 구조로 원자적이지 않습니다. useWebviewCookieSync.ts의 초기 동기화 effect와 AppState 리스너 effect가 거의 동시에 setTokens를 호출하면(예: 콜드 스타트 시), 둘 다 같은 오래된 storedRefreshToken을 읽은 뒤 나중에 쓰는 호출이 최신 토큰을 덮어쓸 수 있습니다. 이슈 #367이 목표로 하는 "최신 토큰만 저장" 보장이 동시성 상황에서는 완전하지 않습니다.

간단한 in-memory 프라미스 기반 락으로 setTokens 호출을 직렬화하면 이 창을 닫을 수 있습니다.

♻️ 제안 방향
+let setTokensLock: Promise<void> = Promise.resolve();
+
   async setTokens(accessToken: string, refreshToken: string) {
+    const run = async () => {
       const storedRefreshToken = await TokenStorage.getRefreshToken();
       if (storedRefreshToken === refreshToken) return;
       if (storedRefreshToken && !isFresherToken(refreshToken, storedRefreshToken)) return;
       const options = getSecureStoreOptions();
       await Promise.all([...]);
+    };
+    setTokensLock = setTokensLock.then(run);
+    return setTokensLock;
   },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/app/utils/tokenStorage.ts` around lines 15 - 21, setTokens의
storedRefreshToken 조회와 토큰 저장을 in-memory Promise 기반 락으로 직렬화해 동시 호출 시에도 한 번에 하나만
최신성 검사를 수행하도록 수정하세요. 각 호출은 이전 작업 완료를 기다린 뒤 기존 isFresherToken 검사를 실행하고, 저장 작업이
성공하거나 실패해도 락이 반드시 해제되도록 하며, 최신 토큰만 저장하는 현재 동작은 유지하세요.
apps/web/src/proxy.ts (1)

46-60: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

쿠키 Max-Age append 로직 중복 — 헬퍼로 추출 권장.

handleGuestLoginhandleTokenRefresh에서 cookieOptions 구성, getTokenMaxAge 계산, set-cookie append 로직이 거의 동일하게 중복되어 있습니다. 공용 헬퍼(예: appendAppAuthCookies(response, bodyAccess, bodyRefresh))로 추출하면 유지보수성이 좋아지고, 두 곳의 Max-Age 처리 방식이 향후 드리프트되는 것을 방지할 수 있습니다.

♻️ 제안 방향
+const appendAppAuthCookies = (
+  response: NextResponse,
+  bodyAccess: string | null | undefined,
+  bodyRefresh: string | null | undefined
+) => {
+  if (!bodyAccess || !bodyRefresh) return;
+  const cookieOptions = `Path=/; SameSite=Lax${process.env.NODE_ENV === 'production' ? '; Secure' : ''}`;
+  const accessMaxAge = getTokenMaxAge(bodyAccess);
+  const refreshMaxAge = getTokenMaxAge(bodyRefresh);
+  response.headers.append(
+    'set-cookie',
+    `access_token=${bodyAccess}; ${cookieOptions}${accessMaxAge === null ? '' : `; Max-Age=${accessMaxAge}`}`
+  );
+  response.headers.append(
+    'set-cookie',
+    `refresh_token=${bodyRefresh}; ${cookieOptions}${refreshMaxAge === null ? '' : `; Max-Age=${refreshMaxAge}`}`
+  );
+};

Also applies to: 150-164

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/proxy.ts` around lines 46 - 60, Extract the duplicated app
authentication cookie logic from handleGuestLogin and handleTokenRefresh into a
shared helper such as appendAppAuthCookies(response, bodyAccess, bodyRefresh).
Move cookieOptions construction, getTokenMaxAge calls, and both set-cookie
appends into the helper, then replace both inline implementations with helper
calls while preserving the existing conditional behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/app/app/index.tsx`:
- Around line 147-158: Update the warmup flow around handleWebViewLoadEnd so
isWarmupLoaded transitions even when the initial about:blank onLoadEnd event is
delayed or missing. Add a bounded timeout fallback for the html: '' WebView
load, ensure it is cleared when the warmup load completes, and preserve the
existing normal event-driven transition.

In `@apps/app/hooks/useWebviewCookieSync.ts`:
- Around line 57-62: Handle nullable token fields at both refresh-response
sites: in apps/app/hooks/useWebviewCookieSync.ts lines 57-62, validate both
tokens before TokenStorage.setTokens and follow the existing 401
cleanup/cookie-deletion path when either is missing; in
apps/app/utils/postWishLinkFromShare.ts lines 45-51, validate both tokens before
storing them or retrying postWishLink, preserving the existing failure behavior.

In `@packages/core/src/utils/jwt.ts`:
- Around line 33-37: Separate isTokenValid into an expiration-only helper and
ensure it is not used for authorization decisions. Update the protected-route
logic in proxy.ts to require server-side JWT signature, issuer, and audience
validation or a trusted backend authentication result before returning
NextResponse.next(), while preserving isTokenValid’s expiration-check behavior
for non-authorization uses.
- Around line 78-87: Align getTokenMaxAge with its documented expired-token
contract by returning null when exp has elapsed, rather than clamping the result
to 0. Preserve null for malformed tokens, and update related tests and the
getTokenMaxAge comment to reflect the chosen behavior consistently with the
proxy cookie handling.

---

Nitpick comments:
In `@apps/app/utils/tokenStorage.ts`:
- Around line 15-21: setTokens의 storedRefreshToken 조회와 토큰 저장을 in-memory Promise
기반 락으로 직렬화해 동시 호출 시에도 한 번에 하나만 최신성 검사를 수행하도록 수정하세요. 각 호출은 이전 작업 완료를 기다린 뒤 기존
isFresherToken 검사를 실행하고, 저장 작업이 성공하거나 실패해도 락이 반드시 해제되도록 하며, 최신 토큰만 저장하는 현재 동작은
유지하세요.

In `@apps/web/src/apis/postTokenRefresh.ts`:
- Around line 6-15: Extract the inline token-refresh payload type used by
postTokenRefreshServer into a named PostTokenRefreshServerResponseT type
following the API naming convention, and use that type in the serverApi.post
generic while preserving the existing nullable accessToken and refreshToken
fields.

In `@apps/web/src/proxy.ts`:
- Around line 46-60: Extract the duplicated app authentication cookie logic from
handleGuestLogin and handleTokenRefresh into a shared helper such as
appendAppAuthCookies(response, bodyAccess, bodyRefresh). Move cookieOptions
construction, getTokenMaxAge calls, and both set-cookie appends into the helper,
then replace both inline implementations with helper calls while preserving the
existing conditional behavior.
🪄 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: CHILL

Plan: Pro Plus

Run ID: b8ad9c41-cdc8-470f-b268-06e65f1960fc

📥 Commits

Reviewing files that changed from the base of the PR and between 6247ffd and 489c018.

📒 Files selected for processing (11)
  • apps/app/app/index.tsx
  • apps/app/hooks/useWebviewCookieSync.ts
  • apps/app/utils/postWishLinkFromShare.ts
  • apps/app/utils/tokenStorage.ts
  • apps/web/src/apis/postTokenRefresh.ts
  • apps/web/src/hooks/useFcmTokenSync.ts
  • apps/web/src/proxy.ts
  • apps/web/src/utils/auth.ts
  • apps/web/src/utils/refreshClientToken.ts
  • packages/core/src/index.ts
  • packages/core/src/utils/jwt.ts

Comment thread apps/app/app/index.tsx
Comment thread apps/app/hooks/useWebviewCookieSync.ts
Comment thread packages/core/src/utils/jwt.ts
Comment thread packages/core/src/utils/jwt.ts
iOdiO89 added 2 commits July 23, 2026 16:01
* fix: 내 토너먼트 페이지 게스트도 접근 가능하도록 변경

* fix: 위시 아카이브 로그인 유도 렌더를 try/catch 밖으로 분리
@iOdiO89
iOdiO89 merged commit 160e9ea into dev Jul 23, 2026
7 checks passed
@iOdiO89
iOdiO89 deleted the fix/367-app-login branch July 23, 2026 07:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: 앱(WebView) 로그인 풀림 — 원인 정리 및 수정 작업

1 participant