@lemondouble/lemon-auth 0.7.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,7 +23,7 @@ NEXT_PUBLIC_LEMON_AUTH_MOCK_USER='{"uid":"local-user","nickname":"Local User","p
23
23
  Mock User가 활성화되면:
24
24
 
25
25
  - `getUser()`, `getSession()`, `requireAuth()`, `requireClient()`는 mock user를 로그인 유저처럼 반환합니다.
26
- - `createAuthProxy()`는 mock user를 인증 성공으로 처리하고, `clientId` 승인 체크는 유지합니다.
26
+ - `createAuthProxy()`는 모든 요청을 통과시킵니다. 승인 체크는 DAL에서 수행됩니다.
27
27
  - `loginUrl(redirectUrl)`은 `redirectUrl`을 그대로 반환합니다.
28
28
  - `profileUrl(redirectUrl?)`은 `redirectUrl ?? "/"`를 반환합니다.
29
29
  - `refreshToken()`은 `true`, `logout()`은 no-op 후 `true`를 반환합니다.
@@ -34,7 +34,7 @@ Mock User가 활성화되면:
34
34
  |------|------|------|
35
35
  | `@lemondouble/lemon-auth/server` | Server Components, Route Handlers, Server Actions | JWT 검증, 유저 조회, 인증 체크 |
36
36
  | `@lemondouble/lemon-auth/client` | Client Components | AuthProvider, useAuth 훅, URL 헬퍼 |
37
- | `@lemondouble/lemon-auth/proxy` | proxy.ts | 토큰 자동 갱신 프록시 |
37
+ | `@lemondouble/lemon-auth/proxy` | proxy.ts | 쿠키 기반 optimistic check |
38
38
 
39
39
  ---
40
40
 
@@ -188,32 +188,6 @@ export default async function Page() {
188
188
  | `options.loginRedirectTo` | `string` | `"/"` | 미인증 시 redirect 경로 |
189
189
  | `options.unapprovedRedirectTo` | `string` | `undefined` | 로그인은 됐지만 클라이언트 미승인 시 redirect 경로. 미설정 시 auth-server `/error?code=FORBIDDEN`로 redirect (proxy 동작과 동일) |
190
190
 
191
- ### `refreshTokenFromCookie(refreshTokenCookie, deviceIdCookie?)`
192
-
193
- 서버사이드에서 토큰 갱신을 수행합니다. Route Handler 등에서 수동으로 갱신할 때 사용합니다.
194
-
195
- ```ts
196
- import { refreshTokenFromCookie } from "@lemondouble/lemon-auth/server";
197
-
198
- export async function POST(request: Request) {
199
- const cookieStore = await cookies();
200
- const refreshToken = cookieStore.get("lemon_refresh_token")?.value;
201
- const deviceId = cookieStore.get("device_id")?.value;
202
- if (!refreshToken) return new Response(null, { status: 401 });
203
-
204
- const result = await refreshTokenFromCookie(refreshToken, deviceId);
205
- // result: { ok: boolean, setCookieHeaders: string[] }
206
-
207
- if (!result.ok) return new Response(null, { status: 401 });
208
-
209
- const response = new Response(null, { status: 200 });
210
- for (const header of result.setCookieHeaders) {
211
- response.headers.append("Set-Cookie", header);
212
- }
213
- return response;
214
- }
215
- ```
216
-
217
191
  ### 상수
218
192
 
219
193
  서버 엔트리포인트에서 모든 상수를 re-export합니다.
@@ -237,7 +211,20 @@ import {
237
211
 
238
212
  ## Proxy — `@lemondouble/lemon-auth/proxy`
239
213
 
240
- Next.js 16의 `proxy.ts`에서 사용합니다. 보호 경로 접근 시 JWT 검증 + 만료 임박 토큰 자동 갱신을 처리합니다.
214
+ Next.js 16의 `proxy.ts`에서 사용합니다. **쿠키만 읽는 optimistic check** 만 합니다.
215
+
216
+ > 프록시는 토큰을 갱신하지 않고 JWT 서명도 검증하지 않습니다. 갱신은
217
+ > `<SessionRestore />`(브라우저)가 유일하게 맡고, 진짜 검증은
218
+ > DAL(`getSession` / `requireClient`)이 합니다. 마이그레이션은
219
+ > [0.8.x → 0.9.0](#08x--090-마이그레이션) 참고.
220
+
221
+ Next.js 공식 인증 가이드의 권장 구조를 그대로 따릅니다.
222
+
223
+ > since Proxy runs on every route, including prefetched routes, it's important to
224
+ > only read the session from the cookie (optimistic checks), and avoid database
225
+ > checks to prevent performance issues.
226
+ >
227
+ > — [Next.js — Authentication](https://nextjs.org/docs/app/guides/authentication)
241
228
 
242
229
  ### `createAuthProxy(options?)`
243
230
 
@@ -248,9 +235,7 @@ import { createAuthProxy } from "@lemondouble/lemon-auth/proxy";
248
235
  export default createAuthProxy({
249
236
  publicPaths: ["/", "/about", "/api/public/*"],
250
237
  bypassPaths: ["/workbox-*"],
251
- clientId: process.env.CLIENT_ID,
252
238
  loginRedirectUrl: (request) => request.url,
253
- unapprovedRedirectUrl: "/pending-approval",
254
239
  });
255
240
 
256
241
  export const config = {
@@ -264,28 +249,41 @@ export const config = {
264
249
 
265
250
  | 옵션 | 타입 | 기본값 | 설명 |
266
251
  |------|------|--------|------|
267
- | `publicPaths` | `string[]` | `[]` | 인증 없이 접근 가능한 경로. `*`로 끝나면 prefix 매칭 (예: `"/api/public/*"`) |
268
- | `bypassPaths` | `string[]` | `[]` | proxy가 refresh도 하지 않고 바로 통과시킬 추가 경로. 기본 PWA 경로(`/sw.js`, `/manifest.webmanifest` 등)는 항상 bypass |
269
- | `apiPaths` | `string[]` | `["/api/*"]` | 보호 API 경로. 인증 실패 시 redirect 대신 JSON 응답을 반환한다. disable하려면 `[]` |
270
- | `clientId` | `string` | `undefined` | 설정 시 `approved_clients`에 포함 여부를 체크 |
252
+ | `publicPaths` | `string[]` | `[]` | 로그인 없이 열어둘 경로. 익명이면 통과, 만료 세션이면 복구를 거친다. `*`로 끝나면 prefix 매칭 (예: `"/api/public/*"`) |
253
+ | `bypassPaths` | `string[]` | `[]` | proxy가 아무 판단 없이 통과시킬 추가 경로. 기본 PWA 경로(`/sw.js`, `/manifest.webmanifest` 등)는 항상 bypass |
254
+ | `apiPaths` | `string[]` | `["/api/*"]` | 보호 API 경로. 인증 실패 시 redirect 대신 401 JSON을 반환한다. disable하려면 `[]` |
271
255
  | `loginRedirectUrl` | `string \| (request: NextRequest) => string` | `undefined` | 미인증 시 Google 로그인 후 돌아올 URL. deep link 보존이 필요하면 `(request) => request.url` 사용. 미설정 시 `"/"` 로 redirect |
272
- | `unapprovedRedirectUrl` | `string \| (request: NextRequest) => string` | `undefined` | 로그인은 됐지만 `clientId` 미승인일 때 redirect할 URL. 미설정 시 auth-server `/error?code=FORBIDDEN`로 redirect (무한 loop 방지) |
273
- | `onAuthSuccess` | `(claims, request, response) => Promise<NextResponse> \| NextResponse` | `undefined` | 보호 경로에서 인증 성공 시 호출되는 콜백. DB 동기화, 헤더 주입 등 앱별 로직에 사용 |
256
+ | `restorePath` | `string` | `"/auth/restore"` | `<SessionRestore />` 를 마운트한 경로. 프록시가 자동으로 통과시키므로 `publicPaths` 에 넣지 않아도 된다 |
274
257
 
275
258
  #### 동작 방식
276
259
 
277
- **Bypass 경로** (기본 PWA/정적 리소스 + `bypassPaths`):
278
-
279
- 1. `/sw.js`, `/manifest.webmanifest`, `/manifest.json`, `/favicon.ico` 등은 인증/갱신 없이 항상 통과
280
- 2. 가능하면 `matcher`에서도 제외해 proxy 실행 자체를 피하는 것을 권장
281
-
282
- **보호 경로** (`publicPaths`에 해당하지 않는 경로):
283
-
284
- 1. `lemon_access_token` 쿠키가 있고 만료까지 60초 이상 남았으면 → JWKS 검증 → `onAuthSuccess` 호출 → 통과
285
- 2. 로그인은 됐지만 `clientId` 미승인이면 `unapprovedRedirectUrl`로 redirect
286
- 3. 만료 임박이거나 검증 실패 시 `lemon_refresh_token` + `device_id`로 자동 갱신 시도
287
- 4. 갱신 성공 → 새 토큰 쿠키 설정 → `onAuthSuccess` 호출 → 통과
288
- 5. 갱신 실패 → `loginRedirectUrl`을 `redirect_url`로 담아 로그인 페이지로 redirect
260
+ 요청은 위에서부터 순서대로 판정됩니다.
261
+
262
+ 1. `restorePath` → 통과 (여기서 복구 경로로 보내면 무한 루프)
263
+ 2. bypass 경로 (기본 PWA/정적 리소스 + `bypassPaths`) → 통과
264
+ 3. `lemon_access_token` 이 있고 만료까지 60초 이상 → 통과
265
+ 4. — 여기부터는 만료된 요청 —
266
+ `apiPaths` 매칭 → 공개 경로면 통과, 아니면 `401 { code: "UNAUTHORIZED" }`
267
+ 5. 비-GET 요청 (서버 액션 등) → 통과. 리다이렉트하면 액션 프로토콜이
268
+ 깨지므로, DAL 이 거부하게 둡니다
269
+ 6. `lemon_refresh_token` 있음 → `restorePath?next=<원래 경로>` 로 redirect.
270
+ **공개 경로도 포함입니다**
271
+ 7. 공개 경로 → 통과 (익명 방문자)
272
+ 8. 나머지 → `loginRedirectUrl` 을 담아 로그인 페이지로 redirect
273
+
274
+ 공개 경로도 만료 세션이면 복구를 거치는 이유: 그냥 통과시키면 "로그아웃
275
+ 상태로 렌더됐다가 갱신 후 뒤집히는" 화면이 되고, 그걸 가리려는 우회 코드가
276
+ 앱마다 생깁니다. 복구를 거치면 처음부터 로그인 상태로 렌더됩니다.
277
+
278
+ **프리페치 요청**: 다른 요청과 똑같이 처리합니다. 통과시키면 만료된 토큰으로
279
+ 페이지가 렌더되고 거기서 나온 redirect 가 라우터 캐시에 굳어, 어느 링크를 눌러도
280
+ 같은 곳으로 튀게 됩니다. 반면 프록시가 돌려주는 `restorePath` 리다이렉트는 굳어도
281
+ 해가 없습니다 — 복구 경로는 막다른 길이 아니라 세션을 되살리고 `next` 로
282
+ 되돌려 보내므로 목적지도 보존됩니다.
283
+
284
+ `exp` 만 보고 **서명은 검증하지 않습니다.** 위조 토큰으로 이 검사는 통과할 수
285
+ 있으나 DAL 에서 걸립니다. 프록시의 판단은 "어느 화면으로 보낼까" 이지
286
+ "권한이 있는가" 가 아닙니다. 권한 판단은 반드시 DAL 에서 하십시오.
289
287
 
290
288
  `loginRedirectUrl`은 고정 URL 문자열과 요청별 resolver 함수를 모두 지원합니다.
291
289
 
@@ -301,14 +299,33 @@ createAuthProxy({
301
299
  });
302
300
  ```
303
301
 
304
- **공개 경로** (`publicPaths`에 해당하는 경로):
302
+ ### 0.8.x → 0.9.0 마이그레이션
303
+
304
+ 갱신 주체가 `<SessionRestore />` 하나로 통일되고, 실패 처리가 서버로
305
+ 넘어갔습니다. **auth-server v2026.08.03.2 이상이 먼저 배포되어 있어야 합니다**
306
+ — 죽은 토큰의 401 응답이 세션 쿠키를 지워주는 동작에 의존합니다.
307
+
308
+ **1. `<AutoTokenRefresh />` 제거** — export 자체가 사라졌습니다. 루트
309
+ 레이아웃에서 마운트와 import 를 지웁니다. 공개 경로의 만료 세션은 이제
310
+ 프록시가 복구 경로로 보내 처리합니다.
305
311
 
306
- 1. 토큰이 만료 임박이면 백그라운드로 갱신 (실패해도 통과)
307
- 2. 인증 여부와 무관하게 항상 통과
312
+ **2. `publicPaths` 에서 복구 경로 제거** — `restorePath` 는 프록시가 자동으로
313
+ 통과시킵니다. 남겨둬도 해는 없지만 필요 없습니다.
308
314
 
309
- **API 경로** (`apiPaths` 매칭, 기본 `/api/*`):
315
+ **3. `refreshTokenFromCookie` 제거** — 서버사이드 갱신 진입점이 사라졌습니다.
316
+ 갱신은 브라우저(`<SessionRestore />`)만 합니다.
310
317
 
311
- 보호 경로 중 `apiPaths`에 매칭되는 요청은 인증 실패 시 redirect 대신 JSON 응답을 반환합니다. fetch 호출이 OAuth HTML로 리다이렉트되어 계약이 깨지는 문제를 방지합니다.
318
+ **4. `<SessionRestore failedRedirectUrl>` prop 제거** — 실패해도 `next` 로
319
+ 돌아갑니다. 죽은 토큰의 쿠키는 서버가 지워주므로, 이후 판단(로그인/익명)은
320
+ 프록시와 DAL 이 합니다.
321
+
322
+ **5. 공개 페이지의 "복구 중" 우회 코드 제거** — 공개 경로도 만료 세션이면
323
+ 복구를 거친 뒤 처음부터 로그인 상태로 렌더되므로, refresh 쿠키 유무를 보고
324
+ 스피너를 띄우던 코드는 필요 없습니다.
325
+
326
+ ### API 경로
327
+
328
+ 보호 경로 중 `apiPaths`(기본 `/api/*`)에 매칭되는 요청은 인증 실패 시 redirect 대신 JSON 응답을 반환합니다. fetch 호출이 OAuth HTML로 리다이렉트되어 계약이 깨지는 문제를 방지합니다.
312
329
 
313
330
  | 상황 | 응답 |
314
331
  |------|------|
@@ -390,46 +407,44 @@ export function Nav() {
390
407
  }
391
408
  ```
392
409
 
393
- ### `<AutoTokenRefresh>`
410
+ ### `<SessionRestore>`
394
411
 
395
- PWA 환경에서 service worker 캐시로 인해 proxy가 실행되지 않는 경우를 대비합니다.
396
- `AuthProvider`의 `user`가 null일 때만 토큰 갱신을 시도하고, 성공하면 `router.refresh()`로 서버 컴포넌트를 다시 렌더합니다.
412
+ **토큰을 갱신하는 유일한 주체입니다.** 프록시가 만료된 세션을 이리로 보내고,
413
+ 갱신이 끝나면 `next` 파라미터가 가리키는 곳으로 **하드 네비게이션**
414
+ (`window.location.replace`) 합니다. 소프트 네비는 루트 레이아웃과
415
+ `AuthProvider` 를 다시 렌더하지 않아, 옛 세션 스냅샷이 남은 채 화면이
416
+ 어긋나기 때문입니다.
397
417
 
398
418
  ```tsx
399
- // app/layout.tsx
400
- import { getUser } from "@lemondouble/lemon-auth/server";
401
- import { AuthProvider, AutoTokenRefresh } from "@lemondouble/lemon-auth/client";
419
+ // app/auth/restore/page.tsx
420
+ import { Suspense } from "react";
421
+ import { SessionRestore } from "@lemondouble/lemon-auth/client";
402
422
 
403
- export default async function RootLayout({ children }: { children: React.ReactNode }) {
404
- const user = await getUser();
423
+ export const dynamic = "force-dynamic";
424
+
425
+ export default function RestorePage() {
405
426
  return (
406
- <html lang="ko">
407
- <body>
408
- <AuthProvider user={user}>
409
- <AutoTokenRefresh fallback={<p>세션 복원 중...</p>}>
410
- <p>로그인이 필요합니다</p>
411
- </AutoTokenRefresh>
412
- {children}
413
- </AuthProvider>
414
- </body>
415
- </html>
427
+ <Suspense fallback={null}>
428
+ <SessionRestore fallback={<p>세션 복원 중...</p>} />
429
+ </Suspense>
416
430
  );
417
431
  }
418
432
  ```
419
433
 
420
434
  | prop | 타입 | 필수 | 설명 |
421
435
  |------|------|------|------|
422
- | `fallback` | `React.ReactNode` | **필수** | 토큰 갱신 시도 중 렌더할 UI (스피너 등) |
423
- | `children` | `React.ReactNode` | 선택 | 갱신 실패 시 렌더할 UI (로그인 버튼 등) |
424
-
425
- **동작 방식:**
426
-
427
- | 상황 | user | AutoTokenRefresh |
428
- |------|------|-----------------|
429
- | 정상 요청 (proxy 실행됨) | 있음 | 아무것도 안 함 (skip) |
430
- | PWA 캐시 히트 + 갱신 시도 중 | 없음 | `fallback` 렌더 |
431
- | PWA 캐시 히트 + 유효한 `lemon_refresh_token` | 없음 | 갱신 → `router.refresh()` |
432
- | PWA 캐시 히트 + 만료된 `lemon_refresh_token` | 없음 | 갱신 실패 → `children` 렌더 |
436
+ | `fallback` | `React.ReactNode` | **필수** | 갱신하는 동안 렌더할 UI |
437
+
438
+ - 갱신 실패도 `next` 로 돌아갑니다. 죽은 토큰이면 서버가 401 응답에서 세션
439
+ 쿠키를 지워 주므로, 이후 판단(로그인으로 보낼지, 익명으로 보여줄지)은
440
+ 프록시와 DAL 이 합니다.
441
+ - 서버에 연결하지 못하면(5xx · 네트워크 오류) 로그인으로 보내지 않고
442
+ "다시 시도" 버튼을 보여줍니다. 토큰이 살아 있을 수 있기 때문입니다.
443
+ - 이 경로는 프록시가 자동으로 통과시키므로 `publicPaths` 에 넣지 않아도
444
+ 됩니다.
445
+ - `useSearchParams()` 를 쓰므로 `<Suspense>` 로 감싸야 빌드가 통과합니다.
446
+ - `next` 는 같은 출처의 경로만 허용합니다. 열린 리다이렉트가 되지 않도록
447
+ `//evil.com` 같은 값은 `/` 로 떨어뜨립니다.
433
448
 
434
449
  ### `loginUrl(redirectUrl)`
435
450
 
@@ -491,7 +506,9 @@ profileUrl("https://myapp.lemondouble.com/settings")
491
506
 
492
507
  ### `refreshToken()`
493
508
 
494
- 클라이언트에서 토큰 갱신을 수행합니다. 보통 proxy에서 자동으로 처리하므로 직접 호출할 일은 적습니다.
509
+ 클라이언트에서 토큰 갱신을 수행합니다. 갱신은 보통 `<SessionRestore />` 가
510
+ 맡으므로 직접 호출할 일은 적습니다. (승인 완료 후 새 `approved_clients` 를
511
+ 즉시 반영하고 싶을 때 정도)
495
512
 
496
513
  ```ts
497
514
  import { refreshToken } from "@lemondouble/lemon-auth/client";
@@ -544,7 +561,7 @@ interface UserProfile {
544
561
 
545
562
  새 Next.js 16 프로젝트에 인증을 추가하는 권장 예시입니다.
546
563
 
547
- ### 1. proxy.ts — 토큰 자동 갱신
564
+ ### 1. proxy.ts — optimistic check
548
565
 
549
566
  ```ts
550
567
  import {
@@ -553,12 +570,15 @@ import {
553
570
  } from "@lemondouble/lemon-auth/proxy";
554
571
 
555
572
  export default createAuthProxy({
556
- publicPaths: ["/", "/login", "/pending-approval", "/api/public/*"],
573
+ publicPaths: [
574
+ "/",
575
+ "/login",
576
+ "/pending-approval",
577
+ "/api/public/*",
578
+ ],
557
579
  bypassPaths: ["/workbox-*"],
558
580
  apiPaths: DEFAULT_API_PATHS,
559
- clientId: process.env.CLIENT_ID,
560
581
  loginRedirectUrl: (request) => request.url,
561
- unapprovedRedirectUrl: "/pending-approval",
562
582
  });
563
583
 
564
584
  export const config = {
@@ -569,56 +589,68 @@ export const config = {
569
589
  ```
570
590
 
571
591
  `loginRedirectUrl: (request) => request.url`은 로그인 후 사용자가 원래 요청한 path/query로 돌아오게 합니다.
572
- `apiPaths`에 매칭되는 보호 API는 인증 실패 시 redirect 대신 `401/403` JSON 응답을 반환합니다.
592
+ `apiPaths`에 매칭되는 보호 API는 인증 실패 시 redirect 대신 `401` JSON 응답을 반환합니다.
573
593
  기본 PWA 파일(`/sw.js`, `/manifest.webmanifest` 등)은 항상 bypass되며, 앱에서 추가로 생성하는 workbox 파일은 `bypassPaths`에 넣을 수 있습니다.
594
+ 복구 경로(`/auth/restore`)는 프록시가 자동으로 통과시키므로 `publicPaths` 에 넣지 않습니다.
574
595
 
575
- `onAuthSuccess`를 사용하면 인증 성공 후 DB 동기화, 헤더 주입 등 앱별 로직을 끼워넣을 수 있습니다:
596
+ 사용자 DB 동기화 같은 작업은 **프록시가 아니라 DAL 에서** 하십시오. 프록시는
597
+ 프리페치를 포함해 모든 요청에서 돌기 때문에, 그 자리에서 DB 를 건드리면 화면
598
+ 하나 여는 데 쿼리가 여러 번 나갑니다.
576
599
 
577
600
  ```ts
578
- import {
579
- createAuthProxy,
580
- DEFAULT_API_PATHS,
581
- } from "@lemondouble/lemon-auth/proxy";
601
+ // lib/dal.ts
602
+ import { cache } from "react";
603
+ import { getUser } from "@lemondouble/lemon-auth/server";
582
604
  import { upsertUser } from "@/lib/db";
583
605
 
584
- export default createAuthProxy({
585
- publicPaths: ["/", "/login", "/pending-approval", "/api/public/*"],
586
- bypassPaths: ["/workbox-*"],
587
- apiPaths: DEFAULT_API_PATHS,
588
- clientId: process.env.CLIENT_ID,
589
- loginRedirectUrl: (request) => request.url,
590
- unapprovedRedirectUrl: "/pending-approval",
591
- onAuthSuccess: async (claims, request, response) => {
592
- await upsertUser(claims);
593
- response.headers.set("x-user-uid", claims.sub);
594
- response.headers.set("x-user-nickname", encodeURIComponent(claims.nickname));
595
- return response;
596
- },
606
+ export const getCurrentUser = cache(async () => {
607
+ const user = await getUser();
608
+ if (!user) return null;
609
+ await upsertUser(user); // React cache() 덕에 요청당 한 번만 돈다
610
+ return user;
597
611
  });
598
612
  ```
599
613
 
600
- ### 2. app/layout.tsx — AuthProvider + AutoTokenRefresh
614
+ ### 2. app/auth/restore/page.tsx — 세션 복구 착지점
615
+
616
+ ```tsx
617
+ import { Suspense } from "react";
618
+ import { SessionRestore } from "@lemondouble/lemon-auth/client";
619
+
620
+ export const dynamic = "force-dynamic";
621
+
622
+ export default function RestorePage() {
623
+ return (
624
+ <Suspense fallback={null}>
625
+ <SessionRestore fallback={<p>세션 복원 중...</p>} />
626
+ </Suspense>
627
+ );
628
+ }
629
+ ```
630
+
631
+ ### 3. app/layout.tsx — AuthProvider
601
632
 
602
633
  ```tsx
603
634
  import { getUser } from "@lemondouble/lemon-auth/server";
604
- import { AuthProvider, AutoTokenRefresh } from "@lemondouble/lemon-auth/client";
635
+ import { AuthProvider } from "@lemondouble/lemon-auth/client";
605
636
 
606
637
  export default async function RootLayout({ children }: { children: React.ReactNode }) {
607
638
  const user = await getUser();
608
639
  return (
609
640
  <html lang="ko">
610
641
  <body>
611
- <AuthProvider user={user}>
612
- <AutoTokenRefresh fallback={<p>세션 복원 중...</p>} />
613
- {children}
614
- </AuthProvider>
642
+ <AuthProvider user={user}>{children}</AuthProvider>
615
643
  </body>
616
644
  </html>
617
645
  );
618
646
  }
619
647
  ```
620
648
 
621
- ### 3. app/page.tsx — 공개 페이지 (로그인 버튼)
649
+ 레이아웃은 `user` 를 **표시용**으로만 씁니다. 레이아웃은 소프트 네비에서
650
+ 다시 렌더되지 않으므로, 여기서 인증을 판단하면 낡은 스냅샷으로 판단하게
651
+ 됩니다. 게이트는 페이지의 DAL 호출이 맡습니다.
652
+
653
+ ### 4. app/page.tsx — 공개 페이지 (로그인 버튼)
622
654
 
623
655
  ```tsx
624
656
  import { getUser } from "@lemondouble/lemon-auth/server";
@@ -642,7 +674,7 @@ export function LoginButton() {
642
674
  }
643
675
  ```
644
676
 
645
- ### 4. app/dashboard/page.tsx — 보호 페이지
677
+ ### 5. app/dashboard/page.tsx — 보호 페이지
646
678
 
647
679
  ```tsx
648
680
  import { requireAuth } from "@lemondouble/lemon-auth/server";
@@ -653,7 +685,7 @@ export default async function Dashboard() {
653
685
  }
654
686
  ```
655
687
 
656
- ### 5. 로그아웃
688
+ ### 6. 로그아웃
657
689
 
658
690
  ```tsx
659
691
  "use client";
@@ -13,14 +13,36 @@ declare function AuthProvider({ user, children, }: {
13
13
  }): react_jsx_runtime.JSX.Element;
14
14
  declare function useAuth(): AuthContextValue;
15
15
 
16
- declare function AutoTokenRefresh({ children, fallback, }: {
17
- children?: React.ReactNode;
16
+ /**
17
+ * 토큰을 갱신하는 유일한 주체. 프록시가 만료된 세션을 이리로 보낸다.
18
+ *
19
+ * 갱신을 서버(미들웨어)가 아니라 브라우저가 하는 이유는 두 가지다.
20
+ *
21
+ * 1. 미들웨어는 요청마다 별도 인스턴스로 떠서 서로를 모른다. 프리페치가
22
+ * 깔리면 같은 refresh token 으로 갱신이 동시에 나가고, 회전 경쟁이 난다.
23
+ * 브라우저의 복구 경로는 한 번에 하나만 뜬다.
24
+ * 2. 브라우저 요청에는 진짜 UA 와 WAF 통과 쿠키가 실린다. 서버간 fetch 는
25
+ * UA 가 런타임 기본값이라 봇 방어에 걸릴 수 있다.
26
+ *
27
+ * 끝나면 router 가 아니라 window.location 으로 next 에 간다. 소프트 네비는
28
+ * 루트 레이아웃(과 AuthProvider)을 다시 렌더하지 않으므로, 옛 세션 스냅샷이
29
+ * 남아 화면이 어긋나거나 라우터 캐시에 굳은 리다이렉트가 되살아난다.
30
+ *
31
+ * 실패해도 next 로 간다. 죽은 토큰이면 서버가 401 응답에서 세션 쿠키를
32
+ * 지워 주므로(auth-server v2026.08.03.2+), 이후 판단 — 로그인으로 보낼지,
33
+ * 익명으로 보여줄지 — 는 프록시와 DAL 이 알아서 한다.
34
+ */
35
+ declare function SessionRestore({ fallback }: {
18
36
  fallback: React.ReactNode;
19
- }): string | number | bigint | boolean | react.ReactElement<unknown, string | react.JSXElementConstructor<any>> | Iterable<react.ReactNode> | Promise<string | number | bigint | boolean | react.ReactPortal | react.ReactElement<unknown, string | react.JSXElementConstructor<any>> | Iterable<react.ReactNode> | null | undefined> | null;
37
+ }): string | number | bigint | boolean | Iterable<react.ReactNode> | Promise<string | number | bigint | boolean | react.ReactPortal | react.ReactElement<unknown, string | react.JSXElementConstructor<any>> | Iterable<react.ReactNode> | null | undefined> | react_jsx_runtime.JSX.Element | null | undefined;
20
38
 
21
39
  declare function loginUrl(redirectUrl: string): string;
22
40
  declare function profileUrl(redirectUrl?: string): string;
41
+ /**
42
+ * 로그아웃. 쿠키 삭제는 응답의 Set-Cookie 가 한다 — 세션 쿠키는 httpOnly
43
+ * 라서 JS 로는 지울 수 없다.
44
+ */
23
45
  declare function logout(): Promise<boolean>;
24
46
  declare function refreshToken(): Promise<boolean>;
25
47
 
26
- export { AuthProvider, AutoTokenRefresh, LemonUser, loginUrl, logout, profileUrl, refreshToken, useAuth };
48
+ export { AuthProvider, LemonUser, SessionRestore, loginUrl, logout, profileUrl, refreshToken, useAuth };
@@ -1,11 +1,8 @@
1
1
  "use client";
2
2
  import {
3
- ACCESS_TOKEN_COOKIE,
4
- DEVICE_ID_COOKIE,
5
3
  LOGIN_URL,
6
4
  LOGOUT_URL,
7
5
  PROFILE_PAGE_URL,
8
- REFRESH_TOKEN_COOKIE,
9
6
  REFRESH_URL,
10
7
  isMockAuthEnabled
11
8
  } from "../chunk-OX5MDVSR.js";
@@ -27,16 +24,11 @@ function useAuth() {
27
24
  return useContext(AuthContext);
28
25
  }
29
26
 
30
- // src/client/auto-token-refresh.tsx
27
+ // src/client/session-restore.tsx
31
28
  import { useEffect, useState } from "react";
32
- import { useRouter } from "next/navigation";
29
+ import { useSearchParams } from "next/navigation";
33
30
 
34
31
  // src/client/url.ts
35
- var LOGOUT_COOKIE_NAMES = [
36
- ACCESS_TOKEN_COOKIE,
37
- REFRESH_TOKEN_COOKIE,
38
- DEVICE_ID_COOKIE
39
- ];
40
32
  function loginUrl(redirectUrl) {
41
33
  if (isMockAuthEnabled()) return redirectUrl;
42
34
  assertValidLoginRedirectUrl(redirectUrl);
@@ -50,10 +42,7 @@ function profileUrl(redirectUrl) {
50
42
  return PROFILE_PAGE_URL;
51
43
  }
52
44
  async function logout() {
53
- if (isMockAuthEnabled()) {
54
- expireAuthCookies();
55
- return true;
56
- }
45
+ if (isMockAuthEnabled()) return true;
57
46
  try {
58
47
  const res = await fetch(LOGOUT_URL, {
59
48
  method: "POST",
@@ -62,51 +51,24 @@ async function logout() {
62
51
  return res.ok;
63
52
  } catch {
64
53
  return false;
65
- } finally {
66
- expireAuthCookies();
67
54
  }
68
55
  }
69
- async function refreshToken() {
70
- if (isMockAuthEnabled()) return true;
56
+ async function refreshSession() {
57
+ if (isMockAuthEnabled()) return "ok";
71
58
  try {
72
59
  const res = await fetch(REFRESH_URL, {
73
60
  method: "POST",
74
61
  credentials: "include"
75
62
  });
76
- return res.ok;
63
+ if (res.ok) return "ok";
64
+ if (res.status === 401 || res.status === 403) return "unauthorized";
65
+ return "unavailable";
77
66
  } catch {
78
- return false;
79
- }
80
- }
81
- function expireAuthCookies() {
82
- if (typeof document === "undefined") return;
83
- for (const name of LOGOUT_COOKIE_NAMES) {
84
- for (const domain of getCookieDomains()) {
85
- document.cookie = [
86
- `${name}=`,
87
- "Max-Age=0",
88
- "Path=/",
89
- "SameSite=Lax",
90
- domain ? `Domain=${domain}` : ""
91
- ].filter(Boolean).join("; ");
92
- }
67
+ return "unavailable";
93
68
  }
94
69
  }
95
- function getCookieDomains() {
96
- if (typeof window === "undefined") return [""];
97
- const { hostname } = window.location;
98
- if (!hostname || hostname === "localhost" || isIpAddress(hostname)) {
99
- return [""];
100
- }
101
- const labels = hostname.split(".");
102
- const domains = [""];
103
- for (let i = 0; i < labels.length - 1; i += 1) {
104
- domains.push(`.${labels.slice(i).join(".")}`);
105
- }
106
- return domains;
107
- }
108
- function isIpAddress(hostname) {
109
- return /^[\d.]+$/.test(hostname) || hostname.includes(":");
70
+ async function refreshToken() {
71
+ return await refreshSession() === "ok";
110
72
  }
111
73
  function assertValidLoginRedirectUrl(redirectUrl) {
112
74
  let url;
@@ -129,36 +91,56 @@ function isAllowedLoginRedirectHost(hostname) {
129
91
  return normalized === "lemondouble.com" || normalized.endsWith(".lemondouble.com");
130
92
  }
131
93
 
132
- // src/client/auto-token-refresh.tsx
133
- function AutoTokenRefresh({
134
- children,
135
- fallback
136
- }) {
137
- const { isAuthenticated } = useAuth();
138
- const router = useRouter();
139
- const [status, setStatus] = useState("loading");
94
+ // src/client/session-restore.tsx
95
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
96
+ function SessionRestore({ fallback }) {
97
+ const searchParams = useSearchParams();
98
+ const [unavailable, setUnavailable] = useState(false);
140
99
  useEffect(() => {
141
- if (isAuthenticated) return;
142
100
  let ignore = false;
143
- refreshToken().then((ok) => {
101
+ const next = safeNextPath(searchParams.get("next"));
102
+ refreshSession().then((result) => {
144
103
  if (ignore) return;
145
- if (ok) {
146
- router.refresh();
147
- } else {
148
- setStatus("failed");
104
+ if (result === "unavailable") {
105
+ setUnavailable(true);
106
+ return;
149
107
  }
108
+ window.location.replace(next);
150
109
  });
151
110
  return () => {
152
111
  ignore = true;
153
112
  };
154
- }, [isAuthenticated, router]);
155
- if (isAuthenticated) return null;
156
- if (status === "loading") return fallback ?? null;
157
- return children ?? null;
113
+ }, [searchParams]);
114
+ if (unavailable) {
115
+ return /* @__PURE__ */ jsxs(
116
+ "div",
117
+ {
118
+ style: {
119
+ minHeight: "100dvh",
120
+ display: "flex",
121
+ flexDirection: "column",
122
+ alignItems: "center",
123
+ justifyContent: "center",
124
+ gap: 12
125
+ },
126
+ children: [
127
+ /* @__PURE__ */ jsx2("p", { children: "\uC778\uC99D \uC11C\uBC84\uC5D0 \uC5F0\uACB0\uD558\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4." }),
128
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: () => window.location.reload(), children: "\uB2E4\uC2DC \uC2DC\uB3C4" })
129
+ ]
130
+ }
131
+ );
132
+ }
133
+ return fallback;
134
+ }
135
+ function safeNextPath(next) {
136
+ if (!next) return "/";
137
+ if (!next.startsWith("/")) return "/";
138
+ if (next.startsWith("//")) return "/";
139
+ return next;
158
140
  }
159
141
  export {
160
142
  AuthProvider,
161
- AutoTokenRefresh,
143
+ SessionRestore,
162
144
  loginUrl,
163
145
  logout,
164
146
  profileUrl,