@lemondouble/lemon-auth 0.8.0 → 0.9.1

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
@@ -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합니다.
@@ -239,10 +213,10 @@ import {
239
213
 
240
214
  Next.js 16의 `proxy.ts`에서 사용합니다. **쿠키만 읽는 optimistic check** 만 합니다.
241
215
 
242
- > **0.8.0 에서 동작이 바뀌었습니다.** 프록시는 더 이상 토큰을 갱신하지 않고
243
- > JWT 서명도 검증하지 않습니다. 갱신은 `<SessionRestore />`(브라우저)가 맡고,
244
- > 진짜 검증은 DAL(`getSession` / `requireClient`)이 합니다. 마이그레이션은
245
- > [0.7.x → 0.8.0](#07x--080-마이그레이션) 참고.
216
+ > 프록시는 토큰을 갱신하지 않고 JWT 서명도 검증하지 않습니다. 갱신은
217
+ > `<SessionRestore />`(브라우저)가 유일하게 맡고, 진짜 검증은
218
+ > DAL(`getSession` / `requireClient`)이 합니다. 마이그레이션은
219
+ > [0.8.x → 0.9.0](#08x--090-마이그레이션) 참고.
246
220
 
247
221
  Next.js 공식 인증 가이드의 권장 구조를 그대로 따릅니다.
248
222
 
@@ -259,7 +233,7 @@ Next.js 공식 인증 가이드의 권장 구조를 그대로 따릅니다.
259
233
  import { createAuthProxy } from "@lemondouble/lemon-auth/proxy";
260
234
 
261
235
  export default createAuthProxy({
262
- publicPaths: ["/", "/about", "/api/public/*", "/auth/restore"],
236
+ publicPaths: ["/", "/about", "/api/public/*"],
263
237
  bypassPaths: ["/workbox-*"],
264
238
  loginRedirectUrl: (request) => request.url,
265
239
  });
@@ -275,18 +249,31 @@ export const config = {
275
249
 
276
250
  | 옵션 | 타입 | 기본값 | 설명 |
277
251
  |------|------|--------|------|
278
- | `publicPaths` | `string[]` | `[]` | 인증 없이 접근 가능한 경로. `*`로 끝나면 prefix 매칭 (예: `"/api/public/*"`) |
279
- | `bypassPaths` | `string[]` | `[]` | proxy가 refresh도 하지 않고 바로 통과시킬 추가 경로. 기본 PWA 경로(`/sw.js`, `/manifest.webmanifest` 등)는 항상 bypass |
252
+ | `publicPaths` | `string[]` | `[]` | 로그인 없이 열어둘 경로. 익명이면 통과, 만료 세션이면 복구를 거친다. `*`로 끝나면 prefix 매칭 (예: `"/api/public/*"`) |
253
+ | `bypassPaths` | `string[]` | `[]` | proxy가 아무 판단 없이 통과시킬 추가 경로. 기본 PWA 경로(`/sw.js`, `/manifest.webmanifest` 등)는 항상 bypass |
280
254
  | `apiPaths` | `string[]` | `["/api/*"]` | 보호 API 경로. 인증 실패 시 redirect 대신 401 JSON을 반환한다. disable하려면 `[]` |
281
255
  | `loginRedirectUrl` | `string \| (request: NextRequest) => string` | `undefined` | 미인증 시 Google 로그인 후 돌아올 URL. deep link 보존이 필요하면 `(request) => request.url` 사용. 미설정 시 `"/"` 로 redirect |
282
- | `restorePath` | `string` | `"/auth/restore"` | `<SessionRestore />` 를 마운트한 경로. **`publicPaths` 에도 넣어야 한다** (안 넣으면 무한 루프) |
256
+ | `restorePath` | `string` | `"/auth/restore"` | `<SessionRestore />` 를 마운트한 경로. 프록시가 자동으로 통과시키므로 `publicPaths` 에 넣지 않아도 된다 |
283
257
 
284
258
  #### 동작 방식
285
259
 
286
- **Bypass 경로** (기본 PWA/정적 리소스 + `bypassPaths`):
260
+ 요청은 위에서부터 순서대로 판정됩니다.
287
261
 
288
- 1. `/sw.js`, `/manifest.webmanifest`, `/favicon.ico` 등은 항상 통과
289
- 2. 가능하면 `matcher`에서도 제외해 proxy 실행 자체를 피하는 것을 권장
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
+ 앱마다 생깁니다. 복구를 거치면 처음부터 로그인 상태로 렌더됩니다.
290
277
 
291
278
  **프리페치 요청**: 다른 요청과 똑같이 처리합니다. 통과시키면 만료된 토큰으로
292
279
  페이지가 렌더되고 거기서 나온 redirect 가 라우터 캐시에 굳어, 어느 링크를 눌러도
@@ -294,13 +281,6 @@ export const config = {
294
281
  해가 없습니다 — 복구 경로는 막다른 길이 아니라 세션을 되살리고 `next` 로
295
282
  되돌려 보내므로 목적지도 보존됩니다.
296
283
 
297
- **보호 경로** (`publicPaths`에 해당하지 않는 경로):
298
-
299
- 1. `lemon_access_token` 이 있고 만료까지 60초 이상 남았으면 → 통과
300
- 2. 아니고 `apiPaths` 에 해당하면 → `401 { code: "UNAUTHORIZED" }`
301
- 3. 아니고 `lemon_refresh_token` 이 있으면 → `restorePath?next=<원래 경로>` 로 redirect
302
- 4. 둘 다 없으면 → `loginRedirectUrl` 을 담아 로그인 페이지로 redirect
303
-
304
284
  `exp` 만 보고 **서명은 검증하지 않습니다.** 위조 토큰으로 이 검사는 통과할 수
305
285
  있으나 DAL 에서 걸립니다. 프록시의 판단은 "어느 화면으로 보낼까" 이지
306
286
  "권한이 있는가" 가 아닙니다. 권한 판단은 반드시 DAL 에서 하십시오.
@@ -319,59 +299,33 @@ createAuthProxy({
319
299
  });
320
300
  ```
321
301
 
322
- **공개 경로** (`publicPaths`에 해당하는 경로): 아무 판단 없이 통과합니다.
323
- 만료된 세션은 루트 레이아웃의 `<AutoTokenRefresh />` 가 복구합니다.
324
-
325
- ### 0.7.x → 0.8.0 마이그레이션
326
-
327
- 프록시가 갱신을 하지 않게 되면서, 앱이 복구 경로를 마운트해야 합니다.
328
-
329
- **1. `<SessionRestore />` 를 마운트하고 `publicPaths` 에 추가**
330
-
331
- ```tsx
332
- // app/auth/restore/page.tsx
333
- import { Suspense } from "react";
334
- import { SessionRestore } from "@lemondouble/lemon-auth/client";
335
-
336
- export const dynamic = "force-dynamic";
337
-
338
- export default function RestorePage() {
339
- return (
340
- <Suspense fallback={null}>
341
- <SessionRestore fallback={<p>세션 복원 중...</p>} />
342
- </Suspense>
343
- );
344
- }
345
- ```
346
-
347
- ```ts
348
- // proxy.ts
349
- publicPaths: ["/", "/auth/restore", ...],
350
- ```
302
+ ### 0.8.x → 0.9.0 마이그레이션
351
303
 
352
- `useSearchParams()` 를 쓰므로 `<Suspense>` 로 감싸야 빌드가 통과합니다.
304
+ 갱신 주체가 `<SessionRestore />` 하나로 통일되고, 실패 처리가 서버로
305
+ 넘어갔습니다. **auth-server v2026.08.03.2 이상이 먼저 배포되어 있어야 합니다**
306
+ — 죽은 토큰의 401 응답이 세션 쿠키를 지워주는 동작에 의존합니다.
353
307
 
354
- **2. `<AutoTokenRefresh />` 를 루트 레이아웃으로**
308
+ **1. `<AutoTokenRefresh />` 제거** — export 자체가 사라졌습니다. 루트
309
+ 레이아웃에서 마운트와 import 를 지웁니다. 공개 경로의 만료 세션은 이제
310
+ 프록시가 복구 경로로 보내 처리합니다.
355
311
 
356
- 공개 경로에서 만료된 세션을 되살리는 안전망입니다. 특정 페이지에만 두면
357
- 그 페이지를 거치지 않는 경로에는 복구 수단이 없습니다.
312
+ **2. `publicPaths` 에서 복구 경로 제거** — `restorePath` 는 프록시가 자동으로
313
+ 통과시킵니다. 남겨둬도 해는 없지만 필요 없습니다.
358
314
 
359
- **3. 제거된 옵션 정리**
315
+ **3. `refreshTokenFromCookie` 제거** — 서버사이드 갱신 진입점이 사라졌습니다.
316
+ 갱신은 브라우저(`<SessionRestore />`)만 합니다.
360
317
 
361
- | 제거 | 대신 |
362
- |---|---|
363
- | `clientId` | DAL 의 `getSession({ clientId })` / `requireClient(clientId)` |
364
- | `unapprovedRedirectUrl` | `requireClient(clientId, { unapprovedRedirectTo })` |
365
- | `onAuthSuccess` | DAL. **미들웨어에서 DB 를 조회하지 마십시오** (아래) |
318
+ **4. `<SessionRestore failedRedirectUrl>` prop 제거** — 실패해도 `next` 로
319
+ 돌아갑니다. 죽은 토큰의 쿠키는 서버가 지워주므로, 이후 판단(로그인/익명)은
320
+ 프록시와 DAL 이 합니다.
366
321
 
367
- `onAuthSuccess` 는 DB 동기화를 유도하는 훅이었으나, 프록시는 프리페치를 포함해
368
- 모든 요청에서 돌기 때문에 그 자리에서 DB 를 건드리면 안 됩니다. 사용자 동기화
369
- 같은 작업은 DAL(`getUser()` 를 부르는 곳)로 옮기고 React `cache()` 로 요청당
370
- 한 번만 돌게 하십시오.
322
+ **5. 공개 페이지의 "복구 중" 우회 코드 제거** — 공개 경로도 만료 세션이면
323
+ 복구를 거친 뒤 처음부터 로그인 상태로 렌더되므로, refresh 쿠키 유무를 보고
324
+ 스피너를 띄우던 코드는 필요 없습니다.
371
325
 
372
- **API 경로** (`apiPaths` 매칭, 기본 `/api/*`):
326
+ ### API 경로
373
327
 
374
- 보호 경로 중 `apiPaths`에 매칭되는 요청은 인증 실패 시 redirect 대신 JSON 응답을 반환합니다. fetch 호출이 OAuth HTML로 리다이렉트되어 계약이 깨지는 문제를 방지합니다.
328
+ 보호 경로 중 `apiPaths`(기본 `/api/*`)에 매칭되는 요청은 인증 실패 시 redirect 대신 JSON 응답을 반환합니다. fetch 호출이 OAuth HTML로 리다이렉트되어 계약이 깨지는 문제를 방지합니다.
375
329
 
376
330
  | 상황 | 응답 |
377
331
  |------|------|
@@ -453,54 +407,13 @@ export function Nav() {
453
407
  }
454
408
  ```
455
409
 
456
- ### `<AutoTokenRefresh>`
457
-
458
- PWA 환경에서 service worker 캐시로 인해 proxy가 실행되지 않는 경우를 대비합니다.
459
- `AuthProvider`의 `user`가 null일 때만 토큰 갱신을 시도하고, 성공하면 `router.refresh()`로 서버 컴포넌트를 다시 렌더합니다.
460
-
461
- ```tsx
462
- // app/layout.tsx
463
- import { getUser } from "@lemondouble/lemon-auth/server";
464
- import { AuthProvider, AutoTokenRefresh } from "@lemondouble/lemon-auth/client";
465
-
466
- export default async function RootLayout({ children }: { children: React.ReactNode }) {
467
- const user = await getUser();
468
- return (
469
- <html lang="ko">
470
- <body>
471
- <AuthProvider user={user}>
472
- <AutoTokenRefresh fallback={<p>세션 복원 중...</p>}>
473
- <p>로그인이 필요합니다</p>
474
- </AutoTokenRefresh>
475
- {children}
476
- </AuthProvider>
477
- </body>
478
- </html>
479
- );
480
- }
481
- ```
482
-
483
- | prop | 타입 | 필수 | 설명 |
484
- |------|------|------|------|
485
- | `fallback` | `React.ReactNode` | **필수** | 토큰 갱신 시도 중 렌더할 UI (스피너 등) |
486
- | `children` | `React.ReactNode` | 선택 | 갱신 실패 시 렌더할 UI (로그인 버튼 등) |
487
-
488
- **동작 방식:**
489
-
490
- | 상황 | user | AutoTokenRefresh |
491
- |------|------|-----------------|
492
- | 정상 요청 | 있음 | 아무것도 안 함 (skip) |
493
- | 공개 경로 + 갱신 시도 중 | 없음 | `fallback` 렌더 |
494
- | 공개 경로 + 유효한 `lemon_refresh_token` | 없음 | 갱신 → `router.refresh()` |
495
- | 공개 경로 + 만료된 `lemon_refresh_token` | 없음 | 갱신 실패 → `children` 렌더 |
496
-
497
- **루트 레이아웃에 두십시오.** 특정 페이지에만 두면 그 페이지를 거치지 않는
498
- 경로에는 복구 수단이 없습니다.
499
-
500
410
  ### `<SessionRestore>`
501
411
 
502
- 보호 경로에서 access token 이 만료됐을 때 프록시가 보내는 착지점입니다.
503
- `refreshToken()` 을 부른 뒤 `next` 파라미터가 가리키는 곳으로 되돌립니다.
412
+ **토큰을 갱신하는 유일한 주체입니다.** 프록시가 만료된 세션을 이리로 보내고,
413
+ 갱신이 끝나면 `next` 파라미터가 가리키는 곳으로 **하드 네비게이션**
414
+ (`window.location.replace`) 합니다. 소프트 네비는 루트 레이아웃과
415
+ `AuthProvider` 를 다시 렌더하지 않아, 옛 세션 스냅샷이 남은 채 화면이
416
+ 어긋나기 때문입니다.
504
417
 
505
418
  ```tsx
506
419
  // app/auth/restore/page.tsx
@@ -521,18 +434,18 @@ export default function RestorePage() {
521
434
  | prop | 타입 | 필수 | 설명 |
522
435
  |------|------|------|------|
523
436
  | `fallback` | `React.ReactNode` | **필수** | 갱신하는 동안 렌더할 UI |
524
- | `failedRedirectUrl` | `string` | 선택 | 갱신 실패 시 보낼 곳. 기본은 로그인 |
525
437
 
526
- - 이 경로를 프록시의 `publicPaths` 에 **반드시** 넣으십시오. 안 넣으면 프록시가
527
- 복구 경로를 다시 복구 경로로 보내 무한 루프가 됩니다.
438
+ - 갱신 실패도 `next` 로 돌아갑니다. 죽은 토큰이면 서버가 401 응답에서 세션
439
+ 쿠키를 지워 주므로, 이후 판단(로그인으로 보낼지, 익명으로 보여줄지)은
440
+ 프록시와 DAL 이 합니다.
441
+ - 서버에 연결하지 못하면(5xx · 네트워크 오류) 로그인으로 보내지 않고
442
+ "다시 시도" 버튼을 보여줍니다. 토큰이 살아 있을 수 있기 때문입니다.
443
+ - 이 경로는 프록시가 자동으로 통과시키므로 `publicPaths` 에 넣지 않아도
444
+ 됩니다.
528
445
  - `useSearchParams()` 를 쓰므로 `<Suspense>` 로 감싸야 빌드가 통과합니다.
529
446
  - `next` 는 같은 출처의 경로만 허용합니다. 열린 리다이렉트가 되지 않도록
530
447
  `//evil.com` 같은 값은 `/` 로 떨어뜨립니다.
531
448
 
532
- **`<AutoTokenRefresh>` 와의 차이**: `AutoTokenRefresh` 는 공개 경로에서 화면을
533
- 막지 않고 조용히 복구하는 안전망이고, `SessionRestore` 는 보호 경로 진입이
534
- 막혔을 때 갱신하고 원래 목적지로 되돌리는 전용 착지점입니다. 둘 다 두십시오.
535
-
536
449
  ### `loginUrl(redirectUrl)`
537
450
 
538
451
  Google 로그인 URL을 생성합니다. `<a>` 태그나 `window.location.href`에 사용합니다.
@@ -593,7 +506,9 @@ profileUrl("https://myapp.lemondouble.com/settings")
593
506
 
594
507
  ### `refreshToken()`
595
508
 
596
- 클라이언트에서 토큰 갱신을 수행합니다. 보통 proxy에서 자동으로 처리하므로 직접 호출할 일은 적습니다.
509
+ 클라이언트에서 토큰 갱신을 수행합니다. 갱신은 보통 `<SessionRestore />` 가
510
+ 맡으므로 직접 호출할 일은 적습니다. (승인 완료 후 새 `approved_clients` 를
511
+ 즉시 반영하고 싶을 때 정도)
597
512
 
598
513
  ```ts
599
514
  import { refreshToken } from "@lemondouble/lemon-auth/client";
@@ -659,7 +574,6 @@ export default createAuthProxy({
659
574
  "/",
660
575
  "/login",
661
576
  "/pending-approval",
662
- "/auth/restore",
663
577
  "/api/public/*",
664
578
  ],
665
579
  bypassPaths: ["/workbox-*"],
@@ -677,9 +591,7 @@ export const config = {
677
591
  `loginRedirectUrl: (request) => request.url`은 로그인 후 사용자가 원래 요청한 path/query로 돌아오게 합니다.
678
592
  `apiPaths`에 매칭되는 보호 API는 인증 실패 시 redirect 대신 `401` JSON 응답을 반환합니다.
679
593
  기본 PWA 파일(`/sw.js`, `/manifest.webmanifest` 등)은 항상 bypass되며, 앱에서 추가로 생성하는 workbox 파일은 `bypassPaths`에 넣을 수 있습니다.
680
-
681
- **`/auth/restore` 를 `publicPaths` 에 넣는 것을 빠뜨리지 마십시오.** 프록시가
682
- 복구 경로 자신을 다시 복구 경로로 보내 무한 루프가 됩니다.
594
+ 복구 경로(`/auth/restore`)는 프록시가 자동으로 통과시키므로 `publicPaths` 에 넣지 않습니다.
683
595
 
684
596
  사용자 DB 동기화 같은 작업은 **프록시가 아니라 DAL 에서** 하십시오. 프록시는
685
597
  프리페치를 포함해 모든 요청에서 돌기 때문에, 그 자리에서 DB 를 건드리면 화면
@@ -716,27 +628,28 @@ export default function RestorePage() {
716
628
  }
717
629
  ```
718
630
 
719
- ### 3. app/layout.tsx — AuthProvider + AutoTokenRefresh
631
+ ### 3. app/layout.tsx — AuthProvider
720
632
 
721
633
  ```tsx
722
634
  import { getUser } from "@lemondouble/lemon-auth/server";
723
- import { AuthProvider, AutoTokenRefresh } from "@lemondouble/lemon-auth/client";
635
+ import { AuthProvider } from "@lemondouble/lemon-auth/client";
724
636
 
725
637
  export default async function RootLayout({ children }: { children: React.ReactNode }) {
726
638
  const user = await getUser();
727
639
  return (
728
640
  <html lang="ko">
729
641
  <body>
730
- <AuthProvider user={user}>
731
- <AutoTokenRefresh fallback={<p>세션 복원 중...</p>} />
732
- {children}
733
- </AuthProvider>
642
+ <AuthProvider user={user}>{children}</AuthProvider>
734
643
  </body>
735
644
  </html>
736
645
  );
737
646
  }
738
647
  ```
739
648
 
649
+ 레이아웃은 `user` 를 **표시용**으로만 씁니다. 레이아웃은 소프트 네비에서
650
+ 다시 렌더되지 않으므로, 여기서 인증을 판단하면 낡은 스냅샷으로 판단하게
651
+ 됩니다. 게이트는 페이지의 DAL 호출이 맡습니다.
652
+
740
653
  ### 4. app/page.tsx — 공개 페이지 (로그인 버튼)
741
654
 
742
655
  ```tsx
@@ -13,39 +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;
18
- 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;
20
-
21
16
  /**
22
- * 토큰을 갱신하는 유일한 주체.
23
- *
24
- * 프록시는 쿠키만 읽고, access 가 만료됐는데 refresh 가 남아 있으면 이 경로로
25
- * 보낸다. 여기서 브라우저가 직접 갱신한 뒤 원래 가려던 곳으로 돌려보낸다.
17
+ * 토큰을 갱신하는 유일한 주체. 프록시가 만료된 세션을 이리로 보낸다.
26
18
  *
27
19
  * 갱신을 서버(미들웨어)가 아니라 브라우저가 하는 이유는 두 가지다.
28
20
  *
29
21
  * 1. 미들웨어는 요청마다 별도 인스턴스로 떠서 서로를 모른다. 프리페치가
30
22
  * 깔리면 같은 refresh token 으로 갱신이 동시에 나가고, 회전 경쟁이 난다.
31
- * 브라우저는 JS 컨텍스트가 하나라 이 문제가 없다.
23
+ * 브라우저의 복구 경로는 한 번에 하나만 뜬다.
32
24
  * 2. 브라우저 요청에는 진짜 UA 와 WAF 통과 쿠키가 실린다. 서버간 fetch 는
33
25
  * UA 가 런타임 기본값이라 봇 방어에 걸릴 수 있다.
34
26
  *
35
- * 앱은 이 컴포넌트를 프록시의 `restorePath`(기본 `/auth/restore`)에 마운트하고,
36
- * 그 경로를 `publicPaths` 에 넣어야 한다. 안 그러면 프록시가 복구 경로 자신을
37
- * 다시 복구 경로로 보내 무한 루프가 된다.
27
+ * 끝나면 router 가 아니라 window.location 으로 next 에 간다. 소프트 네비는
28
+ * 루트 레이아웃(과 AuthProvider)을 다시 렌더하지 않으므로, 옛 세션 스냅샷이
29
+ * 남아 화면이 어긋나거나 라우터 캐시에 굳은 리다이렉트가 되살아난다.
30
+ *
31
+ * 실패해도 next 로 간다. 죽은 토큰이면 서버가 401 응답에서 세션 쿠키를
32
+ * 지워 주므로(auth-server v2026.08.03.2+), 이후 판단 — 로그인으로 보낼지,
33
+ * 익명으로 보여줄지 — 는 프록시와 DAL 이 알아서 한다.
38
34
  */
39
- declare function SessionRestore({ fallback, failedRedirectUrl, }: {
40
- /** 갱신하는 동안 보여줄 것. */
35
+ declare function SessionRestore({ fallback }: {
41
36
  fallback: React.ReactNode;
42
- /** 갱신 실패 시 보낼 곳. 기본은 로그인. */
43
- failedRedirectUrl?: string;
44
- }): react.ReactNode;
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;
45
38
 
46
39
  declare function loginUrl(redirectUrl: string): string;
47
40
  declare function profileUrl(redirectUrl?: string): string;
41
+ /**
42
+ * 로그아웃. 쿠키 삭제는 응답의 Set-Cookie 가 한다 — 세션 쿠키는 httpOnly
43
+ * 라서 JS 로는 지울 수 없다.
44
+ */
48
45
  declare function logout(): Promise<boolean>;
49
46
  declare function refreshToken(): Promise<boolean>;
50
47
 
51
- export { AuthProvider, AutoTokenRefresh, LemonUser, SessionRestore, 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;
67
+ return "unavailable";
79
68
  }
80
69
  }
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
- }
93
- }
94
- }
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,66 +91,45 @@ 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");
140
- useEffect(() => {
141
- if (isAuthenticated) return;
142
- let ignore = false;
143
- refreshToken().then((ok) => {
144
- if (ignore) return;
145
- if (ok) {
146
- router.refresh();
147
- } else {
148
- setStatus("failed");
149
- }
150
- });
151
- return () => {
152
- ignore = true;
153
- };
154
- }, [isAuthenticated, router]);
155
- if (isAuthenticated) return null;
156
- if (status === "loading") return fallback ?? null;
157
- return children ?? null;
158
- }
159
-
160
94
  // src/client/session-restore.tsx
161
- import { useEffect as useEffect2, useState as useState2 } from "react";
162
- import { useRouter as useRouter2, useSearchParams } from "next/navigation";
163
- function SessionRestore({
164
- fallback,
165
- failedRedirectUrl
166
- }) {
167
- const { isAuthenticated } = useAuth();
168
- const router = useRouter2();
95
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
96
+ function SessionRestore({ fallback }) {
169
97
  const searchParams = useSearchParams();
170
- const [failed, setFailed] = useState2(false);
171
- useEffect2(() => {
98
+ const [unavailable, setUnavailable] = useState(false);
99
+ useEffect(() => {
172
100
  let ignore = false;
173
101
  const next = safeNextPath(searchParams.get("next"));
174
- if (isAuthenticated) {
175
- router.replace(next);
176
- return;
177
- }
178
- refreshToken().then((ok) => {
102
+ refreshSession().then((result) => {
179
103
  if (ignore) return;
180
- if (ok) {
181
- router.replace(next);
104
+ if (result === "unavailable") {
105
+ setUnavailable(true);
182
106
  return;
183
107
  }
184
- setFailed(true);
185
- window.location.href = failedRedirectUrl ?? loginUrl(window.location.origin);
108
+ window.location.replace(next);
186
109
  });
187
110
  return () => {
188
111
  ignore = true;
189
112
  };
190
- }, [isAuthenticated, router, searchParams, failedRedirectUrl]);
191
- if (failed) return 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
+ }
192
133
  return fallback;
193
134
  }
194
135
  function safeNextPath(next) {
@@ -199,7 +140,6 @@ function safeNextPath(next) {
199
140
  }
200
141
  export {
201
142
  AuthProvider,
202
- AutoTokenRefresh,
203
143
  SessionRestore,
204
144
  loginUrl,
205
145
  logout,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/client/context.tsx","../../src/client/auto-token-refresh.tsx","../../src/client/url.ts","../../src/client/session-restore.tsx"],"sourcesContent":["\"use client\";\n\nimport { createContext, useContext } from \"react\";\nimport type { LemonUser } from \"../types.js\";\n\ninterface AuthContextValue {\n user: LemonUser | null;\n isAuthenticated: boolean;\n}\n\nconst AuthContext = createContext<AuthContextValue>({\n user: null,\n isAuthenticated: false,\n});\n\nexport function AuthProvider({\n user,\n children,\n}: {\n user: LemonUser | null;\n children: React.ReactNode;\n}) {\n return (\n <AuthContext value={{ user, isAuthenticated: user !== null }}>\n {children}\n </AuthContext>\n );\n}\n\nexport function useAuth(): AuthContextValue {\n return useContext(AuthContext);\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport { useAuth } from \"./context.js\";\nimport { refreshToken } from \"./url.js\";\n\nexport function AutoTokenRefresh({\n children,\n fallback,\n}: {\n children?: React.ReactNode;\n fallback: React.ReactNode;\n}) {\n const { isAuthenticated } = useAuth();\n const router = useRouter();\n const [status, setStatus] = useState<\"loading\" | \"failed\">(\"loading\");\n\n useEffect(() => {\n if (isAuthenticated) return;\n\n let ignore = false;\n\n refreshToken().then((ok) => {\n if (ignore) return;\n if (ok) {\n router.refresh();\n } else {\n setStatus(\"failed\");\n }\n });\n\n return () => {\n ignore = true;\n };\n }, [isAuthenticated, router]);\n\n if (isAuthenticated) return null;\n if (status === \"loading\") return fallback ?? null;\n return children ?? null;\n}\n","import {\n ACCESS_TOKEN_COOKIE,\n DEVICE_ID_COOKIE,\n LOGIN_URL,\n LOGOUT_URL,\n PROFILE_PAGE_URL,\n REFRESH_URL,\n REFRESH_TOKEN_COOKIE,\n} from \"../constants.js\";\nimport { isMockAuthEnabled } from \"../mock.js\";\n\nconst LOGOUT_COOKIE_NAMES = [\n ACCESS_TOKEN_COOKIE,\n REFRESH_TOKEN_COOKIE,\n DEVICE_ID_COOKIE,\n];\n\nexport function loginUrl(redirectUrl: string): string {\n if (isMockAuthEnabled()) return redirectUrl;\n assertValidLoginRedirectUrl(redirectUrl);\n return `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n}\n\nexport function profileUrl(redirectUrl?: string): string {\n if (isMockAuthEnabled()) return redirectUrl ?? \"/\";\n if (redirectUrl) {\n return `${PROFILE_PAGE_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n }\n return PROFILE_PAGE_URL;\n}\n\nexport async function logout(): Promise<boolean> {\n if (isMockAuthEnabled()) {\n expireAuthCookies();\n return true;\n }\n\n try {\n const res = await fetch(LOGOUT_URL, {\n method: \"POST\",\n credentials: \"include\",\n });\n return res.ok;\n } catch {\n return false;\n } finally {\n expireAuthCookies();\n }\n}\n\nexport async function refreshToken(): Promise<boolean> {\n if (isMockAuthEnabled()) return true;\n\n try {\n const res = await fetch(REFRESH_URL, {\n method: \"POST\",\n credentials: \"include\",\n });\n return res.ok;\n } catch {\n return false;\n }\n}\n\nfunction expireAuthCookies(): void {\n if (typeof document === \"undefined\") return;\n\n for (const name of LOGOUT_COOKIE_NAMES) {\n for (const domain of getCookieDomains()) {\n document.cookie = [\n `${name}=`,\n \"Max-Age=0\",\n \"Path=/\",\n \"SameSite=Lax\",\n domain ? `Domain=${domain}` : \"\",\n ]\n .filter(Boolean)\n .join(\"; \");\n }\n }\n}\n\nfunction getCookieDomains(): string[] {\n if (typeof window === \"undefined\") return [\"\"];\n\n const { hostname } = window.location;\n if (!hostname || hostname === \"localhost\" || isIpAddress(hostname)) {\n return [\"\"];\n }\n\n const labels = hostname.split(\".\");\n const domains = [\"\"];\n for (let i = 0; i < labels.length - 1; i += 1) {\n domains.push(`.${labels.slice(i).join(\".\")}`);\n }\n return domains;\n}\n\nfunction isIpAddress(hostname: string): boolean {\n return /^[\\d.]+$/.test(hostname) || hostname.includes(\":\");\n}\n\nfunction assertValidLoginRedirectUrl(redirectUrl: string): void {\n let url: URL;\n try {\n url = new URL(redirectUrl);\n } catch {\n throw new Error(\"loginUrl redirectUrl must be a valid absolute URL\");\n }\n\n if (url.protocol !== \"https:\") {\n throw new Error(\"loginUrl redirectUrl must use https\");\n }\n\n if (!isAllowedLoginRedirectHost(url.hostname)) {\n throw new Error(\n \"loginUrl redirectUrl host must be lemondouble.com or a subdomain of lemondouble.com\"\n );\n }\n}\n\nfunction isAllowedLoginRedirectHost(hostname: string): boolean {\n const normalized = hostname.toLowerCase();\n return (\n normalized === \"lemondouble.com\" ||\n normalized.endsWith(\".lemondouble.com\")\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { useAuth } from \"./context.js\";\nimport { loginUrl, refreshToken } from \"./url.js\";\n\n/**\n * 토큰을 갱신하는 유일한 주체.\n *\n * 프록시는 쿠키만 읽고, access 가 만료됐는데 refresh 가 남아 있으면 이 경로로\n * 보낸다. 여기서 브라우저가 직접 갱신한 뒤 원래 가려던 곳으로 돌려보낸다.\n *\n * 갱신을 서버(미들웨어)가 아니라 브라우저가 하는 이유는 두 가지다.\n *\n * 1. 미들웨어는 요청마다 별도 인스턴스로 떠서 서로를 모른다. 프리페치가\n * 깔리면 같은 refresh token 으로 갱신이 동시에 나가고, 회전 경쟁이 난다.\n * 브라우저는 JS 컨텍스트가 하나라 이 문제가 없다.\n * 2. 브라우저 요청에는 진짜 UA 와 WAF 통과 쿠키가 실린다. 서버간 fetch 는\n * UA 가 런타임 기본값이라 봇 방어에 걸릴 수 있다.\n *\n * 앱은 이 컴포넌트를 프록시의 `restorePath`(기본 `/auth/restore`)에 마운트하고,\n * 그 경로를 `publicPaths` 에 넣어야 한다. 안 그러면 프록시가 복구 경로 자신을\n * 다시 복구 경로로 보내 무한 루프가 된다.\n */\nexport function SessionRestore({\n fallback,\n failedRedirectUrl,\n}: {\n /** 갱신하는 동안 보여줄 것. */\n fallback: React.ReactNode;\n /** 갱신 실패 시 보낼 곳. 기본은 로그인. */\n failedRedirectUrl?: string;\n}) {\n const { isAuthenticated } = useAuth();\n const router = useRouter();\n const searchParams = useSearchParams();\n const [failed, setFailed] = useState(false);\n\n useEffect(() => {\n let ignore = false;\n const next = safeNextPath(searchParams.get(\"next\"));\n\n // 이미 세션이 살아 있으면 갱신하지 않는다. 만료 구간에 굳은 리다이렉트를\n // 뒤늦게 따라오는 경우가 있는데, 그때마다 토큰을 회전시키면 회전 경쟁의\n // 빌미만 늘린다.\n if (isAuthenticated) {\n router.replace(next);\n return;\n }\n\n refreshToken().then((ok) => {\n if (ignore) return;\n\n if (ok) {\n router.replace(next);\n return;\n }\n\n // 갱신이 실패했으면 refresh token 이 죽은 것이다. 다시 시도해봐야\n // 같은 결과이므로 로그인으로 보낸다. 여기서 복구 경로로 되돌리면\n // 무한 루프가 된다.\n setFailed(true);\n window.location.href =\n failedRedirectUrl ?? loginUrl(window.location.origin);\n });\n\n return () => {\n ignore = true;\n };\n }, [isAuthenticated, router, searchParams, failedRedirectUrl]);\n\n if (failed) return null;\n return fallback;\n}\n\n/**\n * next 파라미터는 사용자가 조작할 수 있으므로 열린 리다이렉트가 되지 않게\n * 같은 출처의 경로만 허용한다. `//evil.com` 은 브라우저가 프로토콜 상대\n * URL 로 읽으므로 반드시 걸러야 한다.\n */\nfunction safeNextPath(next: string | null): string {\n if (!next) return \"/\";\n if (!next.startsWith(\"/\")) return \"/\";\n if (next.startsWith(\"//\")) return \"/\";\n return next;\n}\n"],"mappings":";;;;;;;;;;;;AAEA,SAAS,eAAe,kBAAkB;AAqBtC;AAbJ,IAAM,cAAc,cAAgC;AAAA,EAClD,MAAM;AAAA,EACN,iBAAiB;AACnB,CAAC;AAEM,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AACF,GAGG;AACD,SACE,oBAAC,eAAY,OAAO,EAAE,MAAM,iBAAiB,SAAS,KAAK,GACxD,UACH;AAEJ;AAEO,SAAS,UAA4B;AAC1C,SAAO,WAAW,WAAW;AAC/B;;;AC7BA,SAAS,WAAW,gBAAgB;AACpC,SAAS,iBAAiB;;;ACQ1B,IAAM,sBAAsB;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AACF;AAEO,SAAS,SAAS,aAA6B;AACpD,MAAI,kBAAkB,EAAG,QAAO;AAChC,8BAA4B,WAAW;AACvC,SAAO,GAAG,SAAS,iBAAiB,mBAAmB,WAAW,CAAC;AACrE;AAEO,SAAS,WAAW,aAA8B;AACvD,MAAI,kBAAkB,EAAG,QAAO,eAAe;AAC/C,MAAI,aAAa;AACf,WAAO,GAAG,gBAAgB,iBAAiB,mBAAmB,WAAW,CAAC;AAAA,EAC5E;AACA,SAAO;AACT;AAEA,eAAsB,SAA2B;AAC/C,MAAI,kBAAkB,GAAG;AACvB,sBAAkB;AAClB,WAAO;AAAA,EACT;AAEA,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,YAAY;AAAA,MAClC,QAAQ;AAAA,MACR,aAAa;AAAA,IACf,CAAC;AACD,WAAO,IAAI;AAAA,EACb,QAAQ;AACN,WAAO;AAAA,EACT,UAAE;AACA,sBAAkB;AAAA,EACpB;AACF;AAEA,eAAsB,eAAiC;AACrD,MAAI,kBAAkB,EAAG,QAAO;AAEhC,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,aAAa;AAAA,MACnC,QAAQ;AAAA,MACR,aAAa;AAAA,IACf,CAAC;AACD,WAAO,IAAI;AAAA,EACb,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,oBAA0B;AACjC,MAAI,OAAO,aAAa,YAAa;AAErC,aAAW,QAAQ,qBAAqB;AACtC,eAAW,UAAU,iBAAiB,GAAG;AACvC,eAAS,SAAS;AAAA,QAChB,GAAG,IAAI;AAAA,QACP;AAAA,QACA;AAAA,QACA;AAAA,QACA,SAAS,UAAU,MAAM,KAAK;AAAA,MAChC,EACG,OAAO,OAAO,EACd,KAAK,IAAI;AAAA,IACd;AAAA,EACF;AACF;AAEA,SAAS,mBAA6B;AACpC,MAAI,OAAO,WAAW,YAAa,QAAO,CAAC,EAAE;AAE7C,QAAM,EAAE,SAAS,IAAI,OAAO;AAC5B,MAAI,CAAC,YAAY,aAAa,eAAe,YAAY,QAAQ,GAAG;AAClE,WAAO,CAAC,EAAE;AAAA,EACZ;AAEA,QAAM,SAAS,SAAS,MAAM,GAAG;AACjC,QAAM,UAAU,CAAC,EAAE;AACnB,WAAS,IAAI,GAAG,IAAI,OAAO,SAAS,GAAG,KAAK,GAAG;AAC7C,YAAQ,KAAK,IAAI,OAAO,MAAM,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE;AAAA,EAC9C;AACA,SAAO;AACT;AAEA,SAAS,YAAY,UAA2B;AAC9C,SAAO,WAAW,KAAK,QAAQ,KAAK,SAAS,SAAS,GAAG;AAC3D;AAEA,SAAS,4BAA4B,aAA2B;AAC9D,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,IAAI,WAAW;AAAA,EAC3B,QAAQ;AACN,UAAM,IAAI,MAAM,mDAAmD;AAAA,EACrE;AAEA,MAAI,IAAI,aAAa,UAAU;AAC7B,UAAM,IAAI,MAAM,qCAAqC;AAAA,EACvD;AAEA,MAAI,CAAC,2BAA2B,IAAI,QAAQ,GAAG;AAC7C,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;AAEA,SAAS,2BAA2B,UAA2B;AAC7D,QAAM,aAAa,SAAS,YAAY;AACxC,SACE,eAAe,qBACf,WAAW,SAAS,kBAAkB;AAE1C;;;ADxHO,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA;AACF,GAGG;AACD,QAAM,EAAE,gBAAgB,IAAI,QAAQ;AACpC,QAAM,SAAS,UAAU;AACzB,QAAM,CAAC,QAAQ,SAAS,IAAI,SAA+B,SAAS;AAEpE,YAAU,MAAM;AACd,QAAI,gBAAiB;AAErB,QAAI,SAAS;AAEb,iBAAa,EAAE,KAAK,CAAC,OAAO;AAC1B,UAAI,OAAQ;AACZ,UAAI,IAAI;AACN,eAAO,QAAQ;AAAA,MACjB,OAAO;AACL,kBAAU,QAAQ;AAAA,MACpB;AAAA,IACF,CAAC;AAED,WAAO,MAAM;AACX,eAAS;AAAA,IACX;AAAA,EACF,GAAG,CAAC,iBAAiB,MAAM,CAAC;AAE5B,MAAI,gBAAiB,QAAO;AAC5B,MAAI,WAAW,UAAW,QAAO,YAAY;AAC7C,SAAO,YAAY;AACrB;;;AEtCA,SAAS,aAAAA,YAAW,YAAAC,iBAAgB;AACpC,SAAS,aAAAC,YAAW,uBAAuB;AAsBpC,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AACF,GAKG;AACD,QAAM,EAAE,gBAAgB,IAAI,QAAQ;AACpC,QAAM,SAASC,WAAU;AACzB,QAAM,eAAe,gBAAgB;AACrC,QAAM,CAAC,QAAQ,SAAS,IAAIC,UAAS,KAAK;AAE1C,EAAAC,WAAU,MAAM;AACd,QAAI,SAAS;AACb,UAAM,OAAO,aAAa,aAAa,IAAI,MAAM,CAAC;AAKlD,QAAI,iBAAiB;AACnB,aAAO,QAAQ,IAAI;AACnB;AAAA,IACF;AAEA,iBAAa,EAAE,KAAK,CAAC,OAAO;AAC1B,UAAI,OAAQ;AAEZ,UAAI,IAAI;AACN,eAAO,QAAQ,IAAI;AACnB;AAAA,MACF;AAKA,gBAAU,IAAI;AACd,aAAO,SAAS,OACd,qBAAqB,SAAS,OAAO,SAAS,MAAM;AAAA,IACxD,CAAC;AAED,WAAO,MAAM;AACX,eAAS;AAAA,IACX;AAAA,EACF,GAAG,CAAC,iBAAiB,QAAQ,cAAc,iBAAiB,CAAC;AAE7D,MAAI,OAAQ,QAAO;AACnB,SAAO;AACT;AAOA,SAAS,aAAa,MAA6B;AACjD,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI,CAAC,KAAK,WAAW,GAAG,EAAG,QAAO;AAClC,MAAI,KAAK,WAAW,IAAI,EAAG,QAAO;AAClC,SAAO;AACT;","names":["useEffect","useState","useRouter","useRouter","useState","useEffect"]}
1
+ {"version":3,"sources":["../../src/client/context.tsx","../../src/client/session-restore.tsx","../../src/client/url.ts"],"sourcesContent":["\"use client\";\n\nimport { createContext, useContext } from \"react\";\nimport type { LemonUser } from \"../types.js\";\n\ninterface AuthContextValue {\n user: LemonUser | null;\n isAuthenticated: boolean;\n}\n\nconst AuthContext = createContext<AuthContextValue>({\n user: null,\n isAuthenticated: false,\n});\n\nexport function AuthProvider({\n user,\n children,\n}: {\n user: LemonUser | null;\n children: React.ReactNode;\n}) {\n return (\n <AuthContext value={{ user, isAuthenticated: user !== null }}>\n {children}\n </AuthContext>\n );\n}\n\nexport function useAuth(): AuthContextValue {\n return useContext(AuthContext);\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { refreshSession } from \"./url.js\";\n\n/**\n * 토큰을 갱신하는 유일한 주체. 프록시가 만료된 세션을 이리로 보낸다.\n *\n * 갱신을 서버(미들웨어)가 아니라 브라우저가 하는 이유는 두 가지다.\n *\n * 1. 미들웨어는 요청마다 별도 인스턴스로 떠서 서로를 모른다. 프리페치가\n * 깔리면 같은 refresh token 으로 갱신이 동시에 나가고, 회전 경쟁이 난다.\n * 브라우저의 복구 경로는 한 번에 하나만 뜬다.\n * 2. 브라우저 요청에는 진짜 UA 와 WAF 통과 쿠키가 실린다. 서버간 fetch 는\n * UA 가 런타임 기본값이라 봇 방어에 걸릴 수 있다.\n *\n * 끝나면 router 가 아니라 window.location 으로 next 에 간다. 소프트 네비는\n * 루트 레이아웃(과 AuthProvider)을 다시 렌더하지 않으므로, 옛 세션 스냅샷이\n * 남아 화면이 어긋나거나 라우터 캐시에 굳은 리다이렉트가 되살아난다.\n *\n * 실패해도 next 로 간다. 죽은 토큰이면 서버가 401 응답에서 세션 쿠키를\n * 지워 주므로(auth-server v2026.08.03.2+), 이후 판단 — 로그인으로 보낼지,\n * 익명으로 보여줄지 — 는 프록시와 DAL 이 알아서 한다.\n */\nexport function SessionRestore({ fallback }: { fallback: React.ReactNode }) {\n const searchParams = useSearchParams();\n const [unavailable, setUnavailable] = useState(false);\n\n useEffect(() => {\n let ignore = false;\n const next = safeNextPath(searchParams.get(\"next\"));\n\n refreshSession().then((result) => {\n if (ignore) return;\n\n if (result === \"unavailable\") {\n // 서버 · 네트워크 사정이다. 토큰은 살아 있을 수 있으므로\n // 로그인으로 보내지 않고 재시도만 권한다.\n setUnavailable(true);\n return;\n }\n\n window.location.replace(next);\n });\n\n return () => {\n ignore = true;\n };\n }, [searchParams]);\n\n if (unavailable) {\n return (\n <div\n style={{\n minHeight: \"100dvh\",\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 12,\n }}\n >\n <p>인증 서버에 연결하지 못했습니다.</p>\n <button type=\"button\" onClick={() => window.location.reload()}>\n 다시 시도\n </button>\n </div>\n );\n }\n\n return fallback;\n}\n\n/**\n * next 파라미터는 사용자가 조작할 수 있으므로 열린 리다이렉트가 되지 않게\n * 같은 출처의 경로만 허용한다. `//evil.com` 은 브라우저가 프로토콜 상대\n * URL 로 읽으므로 반드시 걸러야 한다.\n */\nfunction safeNextPath(next: string | null): string {\n if (!next) return \"/\";\n if (!next.startsWith(\"/\")) return \"/\";\n if (next.startsWith(\"//\")) return \"/\";\n return next;\n}\n","import {\n LOGIN_URL,\n LOGOUT_URL,\n PROFILE_PAGE_URL,\n REFRESH_URL,\n} from \"../constants.js\";\nimport { isMockAuthEnabled } from \"../mock.js\";\n\nexport function loginUrl(redirectUrl: string): string {\n if (isMockAuthEnabled()) return redirectUrl;\n assertValidLoginRedirectUrl(redirectUrl);\n return `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n}\n\nexport function profileUrl(redirectUrl?: string): string {\n if (isMockAuthEnabled()) return redirectUrl ?? \"/\";\n if (redirectUrl) {\n return `${PROFILE_PAGE_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n }\n return PROFILE_PAGE_URL;\n}\n\n/**\n * 로그아웃. 쿠키 삭제는 응답의 Set-Cookie 가 한다 — 세션 쿠키는 httpOnly\n * 라서 JS 로는 지울 수 없다.\n */\nexport async function logout(): Promise<boolean> {\n if (isMockAuthEnabled()) return true;\n\n try {\n const res = await fetch(LOGOUT_URL, {\n method: \"POST\",\n credentials: \"include\",\n });\n return res.ok;\n } catch {\n return false;\n }\n}\n\nexport type RefreshSessionResult = \"ok\" | \"unauthorized\" | \"unavailable\";\n\n/**\n * 세션 갱신. 쿠키 반영은 응답의 Set-Cookie 가 한다.\n *\n * \"unauthorized\" 는 refresh token 이 죽은 것이고, 서버가 그 응답에서 세션\n * 쿠키를 함께 지워준다. \"unavailable\" 은 서버 · 네트워크 사정이므로 세션이\n * 죽었다고 판단하면 안 된다.\n */\nexport async function refreshSession(): Promise<RefreshSessionResult> {\n if (isMockAuthEnabled()) return \"ok\";\n\n try {\n const res = await fetch(REFRESH_URL, {\n method: \"POST\",\n credentials: \"include\",\n });\n if (res.ok) return \"ok\";\n if (res.status === 401 || res.status === 403) return \"unauthorized\";\n return \"unavailable\";\n } catch {\n return \"unavailable\";\n }\n}\n\nexport async function refreshToken(): Promise<boolean> {\n return (await refreshSession()) === \"ok\";\n}\n\nfunction assertValidLoginRedirectUrl(redirectUrl: string): void {\n let url: URL;\n try {\n url = new URL(redirectUrl);\n } catch {\n throw new Error(\"loginUrl redirectUrl must be a valid absolute URL\");\n }\n\n if (url.protocol !== \"https:\") {\n throw new Error(\"loginUrl redirectUrl must use https\");\n }\n\n if (!isAllowedLoginRedirectHost(url.hostname)) {\n throw new Error(\n \"loginUrl redirectUrl host must be lemondouble.com or a subdomain of lemondouble.com\"\n );\n }\n}\n\nfunction isAllowedLoginRedirectHost(hostname: string): boolean {\n const normalized = hostname.toLowerCase();\n return (\n normalized === \"lemondouble.com\" ||\n normalized.endsWith(\".lemondouble.com\")\n );\n}\n"],"mappings":"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","names":["jsx"]}
@@ -11,7 +11,7 @@ declare const PROXY_AUTH_ERROR: {
11
11
  type ProxyAuthErrorCode = (typeof PROXY_AUTH_ERROR)[keyof typeof PROXY_AUTH_ERROR];
12
12
  type LoginRedirectUrl = string | ((request: NextRequest) => string);
13
13
  interface AuthProxyOptions {
14
- /** 로그인 없이 열어둘 경로. 여기서는 아무 판단도 하지 않고 통과시킨다. */
14
+ /** 로그인 없이 열어둘 경로. 익명이면 통과, 만료 세션이면 복구를 거친다. */
15
15
  publicPaths?: string[];
16
16
  /** 프록시 자체를 건너뛸 경로. 정적 파일 등. */
17
17
  bypassPaths?: string[];
@@ -39,10 +39,10 @@ function createAuthProxy(options = {}) {
39
39
  const effectiveBypassPaths = [...DEFAULT_AUTH_BYPASS_PATHS, ...bypassPaths];
40
40
  return async function proxy(request) {
41
41
  const { pathname } = request.nextUrl;
42
- if (isPublicPath(pathname, effectiveBypassPaths)) {
42
+ if (pathname === restorePath) {
43
43
  return NextResponse.next();
44
44
  }
45
- if (isPublicPath(pathname, publicPaths)) {
45
+ if (isPublicPath(pathname, effectiveBypassPaths)) {
46
46
  return NextResponse.next();
47
47
  }
48
48
  if (isMockAuthEnabled()) {
@@ -52,18 +52,27 @@ function createAuthProxy(options = {}) {
52
52
  if (accessToken && isTokenFresh(accessToken)) {
53
53
  return NextResponse.next();
54
54
  }
55
- const isApiRequest = isPublicPath(pathname, apiPaths);
56
- if (isApiRequest) {
55
+ const isPublic = isPublicPath(pathname, publicPaths);
56
+ if (isPublicPath(pathname, apiPaths)) {
57
+ if (isPublic) {
58
+ return NextResponse.next();
59
+ }
57
60
  return NextResponse.json(
58
61
  { code: PROXY_AUTH_ERROR.UNAUTHORIZED },
59
62
  { status: 401 }
60
63
  );
61
64
  }
65
+ if (request.method !== "GET") {
66
+ return NextResponse.next();
67
+ }
62
68
  if (request.cookies.get(REFRESH_TOKEN_COOKIE)?.value) {
63
69
  const restore = new URL(restorePath, request.url);
64
70
  restore.searchParams.set("next", pathname + request.nextUrl.search);
65
71
  return NextResponse.redirect(restore);
66
72
  }
73
+ if (isPublic) {
74
+ return NextResponse.next();
75
+ }
67
76
  return redirectToLogin(request, loginRedirectUrl);
68
77
  };
69
78
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/proxy/index.ts"],"sourcesContent":["import { NextRequest, NextResponse } from \"next/server\";\nimport {\n ACCESS_TOKEN_COOKIE,\n REFRESH_TOKEN_COOKIE,\n LOGIN_URL,\n} from \"../constants.js\";\nimport { isMockAuthEnabled } from \"../mock.js\";\n\n/**\n * 프록시는 optimistic check 만 한다.\n *\n * Next.js 공식 인증 가이드가 이렇게 못박고 있다.\n *\n * since Proxy runs on every route, including prefetched routes, it's\n * important to only read the session from the cookie (optimistic checks),\n * and avoid database checks to prevent performance issues.\n *\n * 0.7.x 까지는 여기서 auth 서버로 refresh 요청을 보냈다. 그 결과 프리페치가\n * 깔릴 때마다 갱신이 동시에 여러 번 나갔고, Vercel 미들웨어는 요청마다 별도\n * 인스턴스로 뜨므로 직렬화할 방법도 없었다. 회전 경쟁이 나서 정상 요청이\n * 탈취로 오인되고 계정 토큰이 통째로 폐기되는 일이 반복됐다.\n *\n * 그래서 갱신 주체를 브라우저 하나로 옮겼다. 브라우저는 JS 컨텍스트가 하나라\n * 자연히 직렬화된다. 프록시는 쿠키만 읽고, 갱신이 필요하면 앱이 마운트한\n * 복구 경로로 넘긴다.\n *\n * 서명 검증도 하지 않는다. optimistic check 는 보안 판단이 아니라 화면 이동\n * 판단이다. 진짜 검증은 DAL(getSession / requireApprovedUser)이 한다.\n */\n\nconst REFRESH_BUFFER_SECONDS = 60;\n\nexport const DEFAULT_AUTH_BYPASS_PATHS = [\n \"/sw.js\",\n \"/service-worker.js\",\n \"/manifest.webmanifest\",\n \"/manifest.json\",\n \"/favicon.ico\",\n \"/robots.txt\",\n \"/sitemap.xml\",\n \"/browserconfig.xml\",\n \"/apple-touch-icon.png\",\n \"/apple-touch-icon-*\",\n \"/icon-*\",\n \"/icons/*\",\n];\n\nexport const DEFAULT_API_PATHS = [\"/api/*\"];\n\n/** 앱이 <SessionRestore /> 를 마운트해야 하는 기본 경로. */\nexport const DEFAULT_RESTORE_PATH = \"/auth/restore\";\n\nexport const PROXY_AUTH_ERROR = {\n UNAUTHORIZED: \"UNAUTHORIZED\",\n FORBIDDEN: \"FORBIDDEN\",\n} as const;\nexport type ProxyAuthErrorCode =\n (typeof PROXY_AUTH_ERROR)[keyof typeof PROXY_AUTH_ERROR];\n\nexport type LoginRedirectUrl = string | ((request: NextRequest) => string);\n\nexport interface AuthProxyOptions {\n /** 로그인 없이 열어둘 경로. 여기서는 아무 판단도 하지 않고 통과시킨다. */\n publicPaths?: string[];\n /** 프록시 자체를 건너뛸 경로. 정적 파일 등. */\n bypassPaths?: string[];\n /** 리다이렉트 대신 401 JSON 을 돌려줄 경로. 기본 `/api/*`. */\n apiPaths?: string[];\n loginRedirectUrl?: LoginRedirectUrl;\n /** <SessionRestore /> 를 마운트한 경로. 기본 `/auth/restore`. */\n restorePath?: string;\n}\n\nexport function createAuthProxy(options: AuthProxyOptions = {}) {\n const {\n publicPaths = [],\n bypassPaths = [],\n apiPaths = DEFAULT_API_PATHS,\n loginRedirectUrl,\n restorePath = DEFAULT_RESTORE_PATH,\n } = options;\n const effectiveBypassPaths = [...DEFAULT_AUTH_BYPASS_PATHS, ...bypassPaths];\n\n return async function proxy(request: NextRequest): Promise<NextResponse> {\n const { pathname } = request.nextUrl;\n\n if (isPublicPath(pathname, effectiveBypassPaths)) {\n return NextResponse.next();\n }\n\n if (isPublicPath(pathname, publicPaths)) {\n return NextResponse.next();\n }\n\n if (isMockAuthEnabled()) {\n return NextResponse.next();\n }\n\n const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;\n if (accessToken && isTokenFresh(accessToken)) {\n return NextResponse.next();\n }\n\n const isApiRequest = isPublicPath(pathname, apiPaths);\n if (isApiRequest) {\n // API 는 리다이렉트를 따라가봐야 HTML 을 받을 뿐이다. 401 을 주고\n // 클라이언트가 복구하게 한다.\n return NextResponse.json(\n { code: PROXY_AUTH_ERROR.UNAUTHORIZED },\n { status: 401 }\n );\n }\n\n // access 는 없지만 refresh 가 남아 있으면 아직 로그인이 풀린 게 아니다.\n // 브라우저가 갱신할 수 있도록 복구 경로로 보낸다.\n //\n // 프리페치도 똑같이 처리한다. 프리페치를 그냥 통과시키면 만료된 토큰으로\n // 페이지가 렌더되고, 거기서 나온 redirect 가 라우터 캐시에 굳어 어느 탭을\n // 눌러도 같은 곳으로 튀게 된다(예전에 겪은 문제다). 반면 여기서 돌려주는\n // 리다이렉트는 굳어도 해가 없다 — 복구 경로는 막다른 길이 아니라 스스로\n // 세션을 되살리고 next 로 되돌려 보내므로 목적지도 보존된다.\n if (request.cookies.get(REFRESH_TOKEN_COOKIE)?.value) {\n const restore = new URL(restorePath, request.url);\n restore.searchParams.set(\"next\", pathname + request.nextUrl.search);\n return NextResponse.redirect(restore);\n }\n\n return redirectToLogin(request, loginRedirectUrl);\n };\n}\n\n/**\n * exp 만 본다. 서명은 검증하지 않는다.\n *\n * 위조한 토큰으로 이 검사를 통과할 수는 있으나, 그래봐야 DAL 에서 걸린다.\n * 프록시의 판단은 \"어느 화면으로 보낼까\" 이지 \"권한이 있는가\" 가 아니다.\n */\nfunction isTokenFresh(token: string): boolean {\n try {\n const parts = token.split(\".\");\n if (parts.length !== 3) return false;\n const payload = JSON.parse(\n Buffer.from(parts[1], \"base64url\").toString()\n ) as { exp?: unknown };\n return (\n typeof payload.exp === \"number\" &&\n payload.exp > Math.floor(Date.now() / 1000) + REFRESH_BUFFER_SECONDS\n );\n } catch {\n return false;\n }\n}\n\nfunction isPublicPath(pathname: string, publicPaths: string[]): boolean {\n return publicPaths.some((p) => {\n if (p.endsWith(\"*\")) {\n return pathname.startsWith(p.slice(0, -1));\n }\n return pathname === p;\n });\n}\n\nfunction redirectToLogin(\n request: NextRequest,\n loginRedirectUrl: LoginRedirectUrl | undefined\n): NextResponse {\n const redirectUrl =\n typeof loginRedirectUrl === \"function\"\n ? loginRedirectUrl(request)\n : loginRedirectUrl;\n\n if (redirectUrl) {\n const url = `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n return NextResponse.redirect(new URL(url, request.url));\n }\n return NextResponse.redirect(new URL(\"/\", request.url));\n}\n"],"mappings":";;;;;;;;AAAA,SAAsB,oBAAoB;AA8B1C,IAAM,yBAAyB;AAExB,IAAM,4BAA4B;AAAA,EACvC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEO,IAAM,oBAAoB,CAAC,QAAQ;AAGnC,IAAM,uBAAuB;AAE7B,IAAM,mBAAmB;AAAA,EAC9B,cAAc;AAAA,EACd,WAAW;AACb;AAkBO,SAAS,gBAAgB,UAA4B,CAAC,GAAG;AAC9D,QAAM;AAAA,IACJ,cAAc,CAAC;AAAA,IACf,cAAc,CAAC;AAAA,IACf,WAAW;AAAA,IACX;AAAA,IACA,cAAc;AAAA,EAChB,IAAI;AACJ,QAAM,uBAAuB,CAAC,GAAG,2BAA2B,GAAG,WAAW;AAE1E,SAAO,eAAe,MAAM,SAA6C;AACvE,UAAM,EAAE,SAAS,IAAI,QAAQ;AAE7B,QAAI,aAAa,UAAU,oBAAoB,GAAG;AAChD,aAAO,aAAa,KAAK;AAAA,IAC3B;AAEA,QAAI,aAAa,UAAU,WAAW,GAAG;AACvC,aAAO,aAAa,KAAK;AAAA,IAC3B;AAEA,QAAI,kBAAkB,GAAG;AACvB,aAAO,aAAa,KAAK;AAAA,IAC3B;AAEA,UAAM,cAAc,QAAQ,QAAQ,IAAI,mBAAmB,GAAG;AAC9D,QAAI,eAAe,aAAa,WAAW,GAAG;AAC5C,aAAO,aAAa,KAAK;AAAA,IAC3B;AAEA,UAAM,eAAe,aAAa,UAAU,QAAQ;AACpD,QAAI,cAAc;AAGhB,aAAO,aAAa;AAAA,QAClB,EAAE,MAAM,iBAAiB,aAAa;AAAA,QACtC,EAAE,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AAUA,QAAI,QAAQ,QAAQ,IAAI,oBAAoB,GAAG,OAAO;AACpD,YAAM,UAAU,IAAI,IAAI,aAAa,QAAQ,GAAG;AAChD,cAAQ,aAAa,IAAI,QAAQ,WAAW,QAAQ,QAAQ,MAAM;AAClE,aAAO,aAAa,SAAS,OAAO;AAAA,IACtC;AAEA,WAAO,gBAAgB,SAAS,gBAAgB;AAAA,EAClD;AACF;AAQA,SAAS,aAAa,OAAwB;AAC5C,MAAI;AACF,UAAM,QAAQ,MAAM,MAAM,GAAG;AAC7B,QAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,UAAM,UAAU,KAAK;AAAA,MACnB,OAAO,KAAK,MAAM,CAAC,GAAG,WAAW,EAAE,SAAS;AAAA,IAC9C;AACA,WACE,OAAO,QAAQ,QAAQ,YACvB,QAAQ,MAAM,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,IAAI;AAAA,EAElD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,aAAa,UAAkB,aAAgC;AACtE,SAAO,YAAY,KAAK,CAAC,MAAM;AAC7B,QAAI,EAAE,SAAS,GAAG,GAAG;AACnB,aAAO,SAAS,WAAW,EAAE,MAAM,GAAG,EAAE,CAAC;AAAA,IAC3C;AACA,WAAO,aAAa;AAAA,EACtB,CAAC;AACH;AAEA,SAAS,gBACP,SACA,kBACc;AACd,QAAM,cACJ,OAAO,qBAAqB,aACxB,iBAAiB,OAAO,IACxB;AAEN,MAAI,aAAa;AACf,UAAM,MAAM,GAAG,SAAS,iBAAiB,mBAAmB,WAAW,CAAC;AACxE,WAAO,aAAa,SAAS,IAAI,IAAI,KAAK,QAAQ,GAAG,CAAC;AAAA,EACxD;AACA,SAAO,aAAa,SAAS,IAAI,IAAI,KAAK,QAAQ,GAAG,CAAC;AACxD;","names":[]}
1
+ {"version":3,"sources":["../../src/proxy/index.ts"],"sourcesContent":["import { NextRequest, NextResponse } from \"next/server\";\nimport {\n ACCESS_TOKEN_COOKIE,\n REFRESH_TOKEN_COOKIE,\n LOGIN_URL,\n} from \"../constants.js\";\nimport { isMockAuthEnabled } from \"../mock.js\";\n\n/**\n * 프록시는 라우팅 결정만 한다: 신선하면 통과, 아니면 복구 경로로.\n *\n * Next.js 공식 인증 가이드의 optimistic check 다. 프리페치를 포함한 모든\n * 요청에서 돌기 때문에 쿠키의 exp 만 읽고, 네트워크 · DB · 서명 검증을\n * 하지 않는다. 보안 판단은 DAL(getSession / requireAuth)이 한다.\n *\n * 갱신 주체는 restorePath 에 마운트된 <SessionRestore /> 하나뿐이다.\n * 공개 경로도 refresh 쿠키가 남아 있으면 복구를 거치게 해서, 만료된 세션이\n * \"로그아웃 상태로 렌더됐다가 뒤늦게 뒤집히는\" 화면을 없앤다. (0.8.x 까지는\n * 이 몫을 레이아웃의 <AutoTokenRefresh /> 가 맡았는데, 복구 경로와 갱신\n * 주체가 둘이 되는 문제가 있었다)\n *\n * 0.7.x 까지는 프록시가 직접 auth 서버로 갱신을 보냈다. 미들웨어는 요청마다\n * 별도 인스턴스로 떠서 직렬화가 안 되므로, 프리페치가 겹치면 같은 refresh\n * token 으로 갱신이 동시에 나가 회전 경쟁이 났고 계정 토큰이 통째로\n * 폐기되는 일이 반복됐다. 다시 여기에 네트워크 호출을 넣지 말 것.\n */\n\nconst REFRESH_BUFFER_SECONDS = 60;\n\nexport const DEFAULT_AUTH_BYPASS_PATHS = [\n \"/sw.js\",\n \"/service-worker.js\",\n \"/manifest.webmanifest\",\n \"/manifest.json\",\n \"/favicon.ico\",\n \"/robots.txt\",\n \"/sitemap.xml\",\n \"/browserconfig.xml\",\n \"/apple-touch-icon.png\",\n \"/apple-touch-icon-*\",\n \"/icon-*\",\n \"/icons/*\",\n];\n\nexport const DEFAULT_API_PATHS = [\"/api/*\"];\n\n/** 앱이 <SessionRestore /> 를 마운트해야 하는 기본 경로. */\nexport const DEFAULT_RESTORE_PATH = \"/auth/restore\";\n\nexport const PROXY_AUTH_ERROR = {\n UNAUTHORIZED: \"UNAUTHORIZED\",\n FORBIDDEN: \"FORBIDDEN\",\n} as const;\nexport type ProxyAuthErrorCode =\n (typeof PROXY_AUTH_ERROR)[keyof typeof PROXY_AUTH_ERROR];\n\nexport type LoginRedirectUrl = string | ((request: NextRequest) => string);\n\nexport interface AuthProxyOptions {\n /** 로그인 없이 열어둘 경로. 익명이면 통과, 만료 세션이면 복구를 거친다. */\n publicPaths?: string[];\n /** 프록시 자체를 건너뛸 경로. 정적 파일 등. */\n bypassPaths?: string[];\n /** 리다이렉트 대신 401 JSON 을 돌려줄 경로. 기본 `/api/*`. */\n apiPaths?: string[];\n loginRedirectUrl?: LoginRedirectUrl;\n /** <SessionRestore /> 를 마운트한 경로. 기본 `/auth/restore`. */\n restorePath?: string;\n}\n\nexport function createAuthProxy(options: AuthProxyOptions = {}) {\n const {\n publicPaths = [],\n bypassPaths = [],\n apiPaths = DEFAULT_API_PATHS,\n loginRedirectUrl,\n restorePath = DEFAULT_RESTORE_PATH,\n } = options;\n const effectiveBypassPaths = [...DEFAULT_AUTH_BYPASS_PATHS, ...bypassPaths];\n\n return async function proxy(request: NextRequest): Promise<NextResponse> {\n const { pathname } = request.nextUrl;\n\n // 복구 경로 자신은 항상 통과. 여기서 복구 경로로 보내면 무한 루프다.\n if (pathname === restorePath) {\n return NextResponse.next();\n }\n\n if (isPublicPath(pathname, effectiveBypassPaths)) {\n return NextResponse.next();\n }\n\n if (isMockAuthEnabled()) {\n return NextResponse.next();\n }\n\n const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;\n if (accessToken && isTokenFresh(accessToken)) {\n return NextResponse.next();\n }\n\n // --- 여기부터는 access token 이 없거나 만료가 임박한 요청 ---\n\n const isPublic = isPublicPath(pathname, publicPaths);\n\n if (isPublicPath(pathname, apiPaths)) {\n // API 는 리다이렉트를 따라가봐야 HTML 을 받을 뿐이다. 401 을 주고\n // 클라이언트가 복구하게 한다.\n if (isPublic) {\n return NextResponse.next();\n }\n return NextResponse.json(\n { code: PROXY_AUTH_ERROR.UNAUTHORIZED },\n { status: 401 }\n );\n }\n\n // 서버 액션 등 비-GET 을 리다이렉트하면 액션 프로토콜이 깨진다.\n // 통과시키고 DAL 이 거부하게 둔다.\n if (request.method !== \"GET\") {\n return NextResponse.next();\n }\n\n // refresh 가 남아 있으면 아직 로그인이 풀린 게 아니다. 브라우저가\n // 갱신할 수 있도록 복구 경로로 보낸다. 프리페치가 이 리다이렉트를\n // 캐시에 굳혀도 해가 없다 — 복구 경로는 스스로 세션을 되살리고\n // next 로 되돌려 보내므로 목적지가 보존된다.\n if (request.cookies.get(REFRESH_TOKEN_COOKIE)?.value) {\n const restore = new URL(restorePath, request.url);\n restore.searchParams.set(\"next\", pathname + request.nextUrl.search);\n return NextResponse.redirect(restore);\n }\n\n if (isPublic) {\n return NextResponse.next();\n }\n\n return redirectToLogin(request, loginRedirectUrl);\n };\n}\n\n/**\n * exp 만 본다. 서명은 검증하지 않는다.\n *\n * 위조한 토큰으로 이 검사를 통과할 수는 있으나, 그래봐야 DAL 에서 걸린다.\n * 프록시의 판단은 \"어느 화면으로 보낼까\" 이지 \"권한이 있는가\" 가 아니다.\n */\nfunction isTokenFresh(token: string): boolean {\n try {\n const parts = token.split(\".\");\n if (parts.length !== 3) return false;\n const payload = JSON.parse(\n Buffer.from(parts[1], \"base64url\").toString()\n ) as { exp?: unknown };\n return (\n typeof payload.exp === \"number\" &&\n payload.exp > Math.floor(Date.now() / 1000) + REFRESH_BUFFER_SECONDS\n );\n } catch {\n return false;\n }\n}\n\nfunction isPublicPath(pathname: string, publicPaths: string[]): boolean {\n return publicPaths.some((p) => {\n if (p.endsWith(\"*\")) {\n return pathname.startsWith(p.slice(0, -1));\n }\n return pathname === p;\n });\n}\n\nfunction redirectToLogin(\n request: NextRequest,\n loginRedirectUrl: LoginRedirectUrl | undefined\n): NextResponse {\n const redirectUrl =\n typeof loginRedirectUrl === \"function\"\n ? loginRedirectUrl(request)\n : loginRedirectUrl;\n\n if (redirectUrl) {\n const url = `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n return NextResponse.redirect(new URL(url, request.url));\n }\n return NextResponse.redirect(new URL(\"/\", request.url));\n}\n"],"mappings":"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","names":[]}
@@ -17,12 +17,6 @@ interface RequireClientOptions {
17
17
  }
18
18
  declare function requireClient(clientId: string, optionsOrRedirectTo?: string | RequireClientOptions): Promise<LemonUser>;
19
19
 
20
- interface RefreshResult {
21
- ok: boolean;
22
- setCookieHeaders: string[];
23
- }
24
- declare function refreshTokenFromCookie(refreshTokenCookie: string, deviceIdCookie?: string): Promise<RefreshResult>;
25
-
26
20
  declare const AUTH_SERVER_URL = "https://auth.lemondouble.com";
27
21
  declare const JWKS_URL = "https://auth.lemondouble.com/.well-known/jwks.json";
28
22
  declare const LOGIN_URL = "https://auth.lemondouble.com/api/oauth2/google/login";
@@ -34,4 +28,4 @@ declare const ACCESS_TOKEN_COOKIE = "lemon_access_token";
34
28
  declare const REFRESH_TOKEN_COOKIE = "lemon_refresh_token";
35
29
  declare const DEVICE_ID_COOKIE = "device_id";
36
30
 
37
- export { ACCESS_TOKEN_COOKIE, AUTH_SERVER_URL, AccessTokenClaims, DEVICE_ID_COOKIE, type GetSessionOptions, JWKS_URL, LOGIN_URL, LOGOUT_URL, LemonSession, LemonUser, PROFILE_PAGE_URL, PROFILE_URL, REFRESH_TOKEN_COOKIE, REFRESH_URL, type RefreshResult, type RequireClientOptions, getSession, getUser, refreshTokenFromCookie, requireAuth, requireClient, verifyAccessToken, verifyAccessTokenString };
31
+ export { ACCESS_TOKEN_COOKIE, AUTH_SERVER_URL, AccessTokenClaims, DEVICE_ID_COOKIE, type GetSessionOptions, JWKS_URL, LOGIN_URL, LOGOUT_URL, LemonSession, LemonUser, PROFILE_PAGE_URL, PROFILE_URL, REFRESH_TOKEN_COOKIE, REFRESH_URL, type RequireClientOptions, getSession, getUser, requireAuth, requireClient, verifyAccessToken, verifyAccessTokenString };
@@ -9,8 +9,7 @@ import {
9
9
  PROFILE_URL,
10
10
  REFRESH_TOKEN_COOKIE,
11
11
  REFRESH_URL,
12
- getMockAccessTokenClaims,
13
- isMockAuthEnabled
12
+ getMockAccessTokenClaims
14
13
  } from "../chunk-OX5MDVSR.js";
15
14
 
16
15
  // src/server/verify.ts
@@ -49,7 +48,10 @@ var jwks = createRemoteJWKSet(new URL(JWKS_URL));
49
48
  async function verify(token) {
50
49
  try {
51
50
  const { payload } = await jwtVerify(token, jwks, {
52
- issuer: AUTH_SERVER_URL
51
+ issuer: AUTH_SERVER_URL,
52
+ // 발급자(자택 k3s)와 검증자(Vercel)의 시계가 몇 초 어긋나면, 방금
53
+ // 발급된 토큰이 nbf 미래로 거부되어 로그인 직후 로그아웃으로 보인다.
54
+ clockTolerance: 10
53
55
  });
54
56
  if (isAccessTokenClaims(payload)) return payload;
55
57
  warnAuth("access token claims shape mismatch");
@@ -141,37 +143,6 @@ async function requireClient(clientId, optionsOrRedirectTo = "/") {
141
143
  }
142
144
  return session.user;
143
145
  }
144
-
145
- // src/server/refresh.ts
146
- async function refreshTokenFromCookie(refreshTokenCookie, deviceIdCookie) {
147
- if (isMockAuthEnabled()) {
148
- return { ok: true, setCookieHeaders: [] };
149
- }
150
- const res = await fetch(REFRESH_URL, {
151
- method: "POST",
152
- headers: {
153
- Cookie: buildRefreshCookieHeader(refreshTokenCookie, deviceIdCookie)
154
- }
155
- });
156
- if (!res.ok) {
157
- warnAuth("token refresh rejected", {
158
- status: res.status,
159
- server: res.headers.get("server"),
160
- cfRay: res.headers.get("cf-ray"),
161
- cfMitigated: res.headers.get("cf-mitigated")
162
- });
163
- return { ok: false, setCookieHeaders: [] };
164
- }
165
- const setCookieHeaders = res.headers.getSetCookie();
166
- return { ok: true, setCookieHeaders };
167
- }
168
- function buildRefreshCookieHeader(refreshTokenCookie, deviceIdCookie) {
169
- const cookies2 = [`${REFRESH_TOKEN_COOKIE}=${refreshTokenCookie}`];
170
- if (deviceIdCookie) {
171
- cookies2.push(`${DEVICE_ID_COOKIE}=${deviceIdCookie}`);
172
- }
173
- return cookies2.join("; ");
174
- }
175
146
  export {
176
147
  ACCESS_TOKEN_COOKIE,
177
148
  AUTH_SERVER_URL,
@@ -185,7 +156,6 @@ export {
185
156
  REFRESH_URL,
186
157
  getSession,
187
158
  getUser,
188
- refreshTokenFromCookie,
189
159
  requireAuth,
190
160
  requireClient,
191
161
  verifyAccessToken,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/verify.ts","../../src/claims.ts","../../src/log.ts","../../src/server/user.ts","../../src/approval.ts","../../src/server/refresh.ts"],"sourcesContent":["import { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport { cookies } from \"next/headers\";\nimport type { AccessTokenClaims } from \"../types.js\";\nimport { JWKS_URL, AUTH_SERVER_URL, ACCESS_TOKEN_COOKIE } from \"../constants.js\";\nimport { isAccessTokenClaims } from \"../claims.js\";\nimport { getMockAccessTokenClaims } from \"../mock.js\";\nimport { warnAuth, errorCode } from \"../log.js\";\n\nconst jwks = createRemoteJWKSet(new URL(JWKS_URL));\n\n/**\n * 검증과 진단을 한 곳에 모은다.\n *\n * 예전에는 아래 두 export 가 같은 try/catch 를 각자 갖고 있었다. 그러면\n * 로깅을 붙일 때도 벌마다 갈리고, 한쪽만 고치는 일이 생긴다.\n */\nasync function verify(token: string): Promise<AccessTokenClaims | null> {\n try {\n const { payload } = await jwtVerify(token, jwks, {\n issuer: AUTH_SERVER_URL,\n });\n if (isAccessTokenClaims(payload)) return payload;\n warnAuth(\"access token claims shape mismatch\");\n return null;\n } catch (error) {\n // 정상 만료는 갱신으로 이어지는 흔한 경로라 남기지 않는다. 나머지는\n // 원인이 전혀 다르다 — 특히 ERR_JWKS_NO_MATCHING_KEY 는 키 로테이션이나\n // JWKS 도달 실패이고, 이걸 위조 토큰과 뭉개면 전 사용자가 조용히\n // 로그아웃된 이유를 알 수 없게 된다.\n const code = errorCode(error);\n if (code !== \"ERR_JWT_EXPIRED\") {\n warnAuth(\"access token verification failed\", { code });\n }\n return null;\n }\n}\n\nexport async function verifyAccessToken(): Promise<AccessTokenClaims | null> {\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) return mockClaims;\n\n const cookieStore = await cookies();\n const token = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;\n if (!token) return null;\n\n return verify(token);\n}\n\nexport async function verifyAccessTokenString(\n token: string\n): Promise<AccessTokenClaims | null> {\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) return mockClaims;\n\n return verify(token);\n}\n","import type { JWTPayload } from \"jose\";\nimport type { AccessTokenClaims } from \"./types.js\";\n\nexport function isAccessTokenClaims(\n payload: JWTPayload\n): payload is AccessTokenClaims {\n return (\n payload.token_type === \"access\" &&\n typeof payload.sub === \"string\" &&\n typeof payload.nickname === \"string\" &&\n typeof payload.profile_image_url === \"string\" &&\n (payload.role === \"user\" || payload.role === \"admin\") &&\n Array.isArray(payload.approved_clients) &&\n payload.approved_clients.every((clientId) => typeof clientId === \"string\")\n );\n}\n","/**\n * 진단 로그.\n *\n * 이 라이브러리는 오랫동안 실패를 전부 `null` 하나로만 알렸다. 2026-07-19 에\n * auth 서버 앞의 Cloudflare Bot Fight Mode 가 프록시의 갱신 요청을 403\n * (`cf-mitigated: challenge`) 으로 막았을 때, 남은 흔적이 하나도 없어서\n * 원인을 찾는 데 반나절이 걸렸다. 앱 쪽에 임시 진단 코드를 심고 프로덕션에\n * 두 번 배포한 뒤에야 응답 코드를 볼 수 있었다.\n *\n * 그래서 실패에는 최소한의 사실을 남긴다.\n *\n * **토큰 값은 절대 남기지 않는다.** JWT 는 base64url 이라 \"접두 8자\" 같은\n * 안전해 보이는 일부만으로도 헤더와 payload 앞부분이 그대로 복원된다.\n * `sub`, `nickname`, `approved_clients` 가 거기 들어 있다. 남길 수 있는 것은\n * 쿠키 **이름**, HTTP 상태 코드, 에러 코드처럼 값이 아닌 사실뿐이다.\n */\n\nconst PREFIX = \"[lemon-auth]\";\n\nexport function warnAuth(\n event: string,\n detail?: Record<string, unknown>\n): void {\n if (detail === undefined) {\n console.warn(`${PREFIX} ${event}`);\n return;\n }\n console.warn(`${PREFIX} ${event}`, JSON.stringify(detail));\n}\n\n/**\n * 에러에서 값 노출 없이 식별자만 뽑는다.\n *\n * jose 는 `ERR_JWKS_NO_MATCHING_KEY`(키 로테이션), `ERR_JWT_EXPIRED`(정상 만료),\n * `ERR_JWS_SIGNATURE_VERIFICATION_FAILED`(위조 또는 설정 오류) 처럼 원인이\n * 전혀 다른 실패를 구분 가능한 `code` 로 준다. 이걸 뭉개면 JWKS 장애와\n * 위조 토큰이 같은 증상으로 보인다.\n *\n * undici 의 네트워크 실패는 `cause.code` 에 `ENOTFOUND` 같은 값이 들어간다.\n */\nexport function errorCode(error: unknown): string {\n if (typeof error === \"object\" && error !== null) {\n const { code, cause } = error as { code?: unknown; cause?: unknown };\n if (typeof code === \"string\") return code;\n if (typeof cause === \"object\" && cause !== null) {\n const causeCode = (cause as { code?: unknown }).code;\n if (typeof causeCode === \"string\") return causeCode;\n }\n }\n if (error instanceof Error) return error.name;\n return \"unknown\";\n}\n","import { cache } from \"react\";\nimport { redirect } from \"next/navigation\";\nimport type { LemonSession, LemonUser } from \"../types.js\";\nimport { AUTH_SERVER_URL } from \"../constants.js\";\nimport { isApprovedClient } from \"../approval.js\";\nimport { verifyAccessToken } from \"./verify.js\";\n\nfunction claimsToUser(\n claims: NonNullable<Awaited<ReturnType<typeof verifyAccessToken>>>\n): LemonUser {\n return {\n uid: claims.sub,\n nickname: claims.nickname,\n profileImageUrl: claims.profile_image_url,\n role: claims.role,\n approvedClients: claims.approved_clients,\n };\n}\n\nexport const getUser = cache(async (): Promise<LemonUser | null> => {\n const claims = await verifyAccessToken();\n if (!claims) return null;\n return claimsToUser(claims);\n});\n\nexport interface GetSessionOptions {\n clientId?: string;\n}\n\nconst getSessionByClientId = cache(\n async (clientId?: string): Promise<LemonSession> => {\n const user = await getUser();\n if (!user) return { type: \"none\" };\n if (!isApprovedClient(user.approvedClients, clientId)) {\n return { type: \"unapproved\", user };\n }\n return { type: \"authenticated\", user };\n }\n);\n\nexport async function getSession(\n options: GetSessionOptions = {}\n): Promise<LemonSession> {\n return getSessionByClientId(options.clientId);\n}\n\nexport async function requireAuth(redirectTo = \"/\"): Promise<LemonUser> {\n const user = await getUser();\n if (user) return user;\n redirect(redirectTo);\n}\n\nexport interface RequireClientOptions {\n loginRedirectTo?: string;\n unapprovedRedirectTo?: string;\n}\n\nexport async function requireClient(\n clientId: string,\n optionsOrRedirectTo: string | RequireClientOptions = \"/\"\n): Promise<LemonUser> {\n const options =\n typeof optionsOrRedirectTo === \"string\"\n ? { loginRedirectTo: optionsOrRedirectTo }\n : optionsOrRedirectTo;\n const session = await getSession({ clientId });\n if (session.type === \"none\") {\n redirect(options.loginRedirectTo ?? \"/\");\n }\n if (session.type === \"unapproved\") {\n redirect(\n options.unapprovedRedirectTo ??\n `${AUTH_SERVER_URL}/error?code=FORBIDDEN`\n );\n }\n return session.user;\n}\n","import { warnAuth } from \"./log.js\";\n\n/** fail-open 경고를 요청마다 찍지 않기 위한 플래그. */\nlet warnedMissingClientId = false;\n\nexport function isApprovedClient(\n approvedClients: string[],\n clientId?: string\n): boolean {\n if (!clientId) {\n // clientId 를 안 넘기면 승인 검사가 통째로 열린다(fail-open). 이 라이브러리를\n // clientId 없이 쓰는 앱도 있으므로 동작은 유지하지만, 환경변수가 배포에서\n // 빠져서 이렇게 된 경우와 구분이 안 된다. 후자는 인증이 사실상 없는 상태라\n // 조용히 넘어가면 안 된다.\n if (!warnedMissingClientId) {\n warnedMissingClientId = true;\n warnAuth(\n \"clientId not provided — approval check is disabled (all users pass). \" +\n \"설정 누락이 아닌지 확인할 것.\"\n );\n }\n return true;\n }\n return approvedClients.includes(clientId) || approvedClients.includes(\"*\");\n}\n","import {\n DEVICE_ID_COOKIE,\n REFRESH_TOKEN_COOKIE,\n REFRESH_URL,\n} from \"../constants.js\";\nimport { isMockAuthEnabled } from \"../mock.js\";\nimport { warnAuth } from \"../log.js\";\n\nexport interface RefreshResult {\n ok: boolean;\n setCookieHeaders: string[];\n}\n\nexport async function refreshTokenFromCookie(\n refreshTokenCookie: string,\n deviceIdCookie?: string\n): Promise<RefreshResult> {\n if (isMockAuthEnabled()) {\n return { ok: true, setCookieHeaders: [] };\n }\n\n const res = await fetch(REFRESH_URL, {\n method: \"POST\",\n headers: {\n Cookie: buildRefreshCookieHeader(refreshTokenCookie, deviceIdCookie),\n },\n });\n\n if (!res.ok) {\n // proxy 의 tryRefresh 와 같은 이유로 남긴다. 이 요청도 서버에서 나가므로\n // auth 서버 앞의 WAF · 봇 방어에 걸리면 origin 에 흔적이 남지 않는다.\n // (이 함수는 proxy 와 달리 네트워크 예외를 잡지 않고 호출자에게 던진다)\n warnAuth(\"token refresh rejected\", {\n status: res.status,\n server: res.headers.get(\"server\"),\n cfRay: res.headers.get(\"cf-ray\"),\n cfMitigated: res.headers.get(\"cf-mitigated\"),\n });\n return { ok: false, setCookieHeaders: [] };\n }\n\n const setCookieHeaders = res.headers.getSetCookie();\n return { ok: true, setCookieHeaders };\n}\n\nfunction buildRefreshCookieHeader(\n refreshTokenCookie: string,\n deviceIdCookie?: string\n): string {\n const cookies = [`${REFRESH_TOKEN_COOKIE}=${refreshTokenCookie}`];\n if (deviceIdCookie) {\n cookies.push(`${DEVICE_ID_COOKIE}=${deviceIdCookie}`);\n }\n return cookies.join(\"; \");\n}\n"],"mappings":"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","names":["cookies"]}
1
+ {"version":3,"sources":["../../src/server/verify.ts","../../src/claims.ts","../../src/log.ts","../../src/server/user.ts","../../src/approval.ts"],"sourcesContent":["import { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport { cookies } from \"next/headers\";\nimport type { AccessTokenClaims } from \"../types.js\";\nimport { JWKS_URL, AUTH_SERVER_URL, ACCESS_TOKEN_COOKIE } from \"../constants.js\";\nimport { isAccessTokenClaims } from \"../claims.js\";\nimport { getMockAccessTokenClaims } from \"../mock.js\";\nimport { warnAuth, errorCode } from \"../log.js\";\n\nconst jwks = createRemoteJWKSet(new URL(JWKS_URL));\n\n/**\n * 검증과 진단을 한 곳에 모은다.\n *\n * 예전에는 아래 두 export 가 같은 try/catch 를 각자 갖고 있었다. 그러면\n * 로깅을 붙일 때도 벌마다 갈리고, 한쪽만 고치는 일이 생긴다.\n */\nasync function verify(token: string): Promise<AccessTokenClaims | null> {\n try {\n const { payload } = await jwtVerify(token, jwks, {\n issuer: AUTH_SERVER_URL,\n // 발급자(자택 k3s)와 검증자(Vercel)의 시계가 몇 초 어긋나면, 방금\n // 발급된 토큰이 nbf 미래로 거부되어 로그인 직후 로그아웃으로 보인다.\n clockTolerance: 10,\n });\n if (isAccessTokenClaims(payload)) return payload;\n warnAuth(\"access token claims shape mismatch\");\n return null;\n } catch (error) {\n // 정상 만료는 갱신으로 이어지는 흔한 경로라 남기지 않는다. 나머지는\n // 원인이 전혀 다르다 — 특히 ERR_JWKS_NO_MATCHING_KEY 는 키 로테이션이나\n // JWKS 도달 실패이고, 이걸 위조 토큰과 뭉개면 전 사용자가 조용히\n // 로그아웃된 이유를 알 수 없게 된다.\n const code = errorCode(error);\n if (code !== \"ERR_JWT_EXPIRED\") {\n warnAuth(\"access token verification failed\", { code });\n }\n return null;\n }\n}\n\nexport async function verifyAccessToken(): Promise<AccessTokenClaims | null> {\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) return mockClaims;\n\n const cookieStore = await cookies();\n const token = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;\n if (!token) return null;\n\n return verify(token);\n}\n\nexport async function verifyAccessTokenString(\n token: string\n): Promise<AccessTokenClaims | null> {\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) return mockClaims;\n\n return verify(token);\n}\n","import type { JWTPayload } from \"jose\";\nimport type { AccessTokenClaims } from \"./types.js\";\n\nexport function isAccessTokenClaims(\n payload: JWTPayload\n): payload is AccessTokenClaims {\n return (\n payload.token_type === \"access\" &&\n typeof payload.sub === \"string\" &&\n typeof payload.nickname === \"string\" &&\n typeof payload.profile_image_url === \"string\" &&\n (payload.role === \"user\" || payload.role === \"admin\") &&\n Array.isArray(payload.approved_clients) &&\n payload.approved_clients.every((clientId) => typeof clientId === \"string\")\n );\n}\n","/**\n * 진단 로그.\n *\n * 이 라이브러리는 오랫동안 실패를 전부 `null` 하나로만 알렸다. 2026-07-19 에\n * auth 서버 앞의 Cloudflare Bot Fight Mode 가 프록시의 갱신 요청을 403\n * (`cf-mitigated: challenge`) 으로 막았을 때, 남은 흔적이 하나도 없어서\n * 원인을 찾는 데 반나절이 걸렸다. 앱 쪽에 임시 진단 코드를 심고 프로덕션에\n * 두 번 배포한 뒤에야 응답 코드를 볼 수 있었다.\n *\n * 그래서 실패에는 최소한의 사실을 남긴다.\n *\n * **토큰 값은 절대 남기지 않는다.** JWT 는 base64url 이라 \"접두 8자\" 같은\n * 안전해 보이는 일부만으로도 헤더와 payload 앞부분이 그대로 복원된다.\n * `sub`, `nickname`, `approved_clients` 가 거기 들어 있다. 남길 수 있는 것은\n * 쿠키 **이름**, HTTP 상태 코드, 에러 코드처럼 값이 아닌 사실뿐이다.\n */\n\nconst PREFIX = \"[lemon-auth]\";\n\nexport function warnAuth(\n event: string,\n detail?: Record<string, unknown>\n): void {\n if (detail === undefined) {\n console.warn(`${PREFIX} ${event}`);\n return;\n }\n console.warn(`${PREFIX} ${event}`, JSON.stringify(detail));\n}\n\n/**\n * 에러에서 값 노출 없이 식별자만 뽑는다.\n *\n * jose 는 `ERR_JWKS_NO_MATCHING_KEY`(키 로테이션), `ERR_JWT_EXPIRED`(정상 만료),\n * `ERR_JWS_SIGNATURE_VERIFICATION_FAILED`(위조 또는 설정 오류) 처럼 원인이\n * 전혀 다른 실패를 구분 가능한 `code` 로 준다. 이걸 뭉개면 JWKS 장애와\n * 위조 토큰이 같은 증상으로 보인다.\n *\n * undici 의 네트워크 실패는 `cause.code` 에 `ENOTFOUND` 같은 값이 들어간다.\n */\nexport function errorCode(error: unknown): string {\n if (typeof error === \"object\" && error !== null) {\n const { code, cause } = error as { code?: unknown; cause?: unknown };\n if (typeof code === \"string\") return code;\n if (typeof cause === \"object\" && cause !== null) {\n const causeCode = (cause as { code?: unknown }).code;\n if (typeof causeCode === \"string\") return causeCode;\n }\n }\n if (error instanceof Error) return error.name;\n return \"unknown\";\n}\n","import { cache } from \"react\";\nimport { redirect } from \"next/navigation\";\nimport type { LemonSession, LemonUser } from \"../types.js\";\nimport { AUTH_SERVER_URL } from \"../constants.js\";\nimport { isApprovedClient } from \"../approval.js\";\nimport { verifyAccessToken } from \"./verify.js\";\n\nfunction claimsToUser(\n claims: NonNullable<Awaited<ReturnType<typeof verifyAccessToken>>>\n): LemonUser {\n return {\n uid: claims.sub,\n nickname: claims.nickname,\n profileImageUrl: claims.profile_image_url,\n role: claims.role,\n approvedClients: claims.approved_clients,\n };\n}\n\nexport const getUser = cache(async (): Promise<LemonUser | null> => {\n const claims = await verifyAccessToken();\n if (!claims) return null;\n return claimsToUser(claims);\n});\n\nexport interface GetSessionOptions {\n clientId?: string;\n}\n\nconst getSessionByClientId = cache(\n async (clientId?: string): Promise<LemonSession> => {\n const user = await getUser();\n if (!user) return { type: \"none\" };\n if (!isApprovedClient(user.approvedClients, clientId)) {\n return { type: \"unapproved\", user };\n }\n return { type: \"authenticated\", user };\n }\n);\n\nexport async function getSession(\n options: GetSessionOptions = {}\n): Promise<LemonSession> {\n return getSessionByClientId(options.clientId);\n}\n\nexport async function requireAuth(redirectTo = \"/\"): Promise<LemonUser> {\n const user = await getUser();\n if (user) return user;\n redirect(redirectTo);\n}\n\nexport interface RequireClientOptions {\n loginRedirectTo?: string;\n unapprovedRedirectTo?: string;\n}\n\nexport async function requireClient(\n clientId: string,\n optionsOrRedirectTo: string | RequireClientOptions = \"/\"\n): Promise<LemonUser> {\n const options =\n typeof optionsOrRedirectTo === \"string\"\n ? { loginRedirectTo: optionsOrRedirectTo }\n : optionsOrRedirectTo;\n const session = await getSession({ clientId });\n if (session.type === \"none\") {\n redirect(options.loginRedirectTo ?? \"/\");\n }\n if (session.type === \"unapproved\") {\n redirect(\n options.unapprovedRedirectTo ??\n `${AUTH_SERVER_URL}/error?code=FORBIDDEN`\n );\n }\n return session.user;\n}\n","import { warnAuth } from \"./log.js\";\n\n/** fail-open 경고를 요청마다 찍지 않기 위한 플래그. */\nlet warnedMissingClientId = false;\n\nexport function isApprovedClient(\n approvedClients: string[],\n clientId?: string\n): boolean {\n if (!clientId) {\n // clientId 를 안 넘기면 승인 검사가 통째로 열린다(fail-open). 이 라이브러리를\n // clientId 없이 쓰는 앱도 있으므로 동작은 유지하지만, 환경변수가 배포에서\n // 빠져서 이렇게 된 경우와 구분이 안 된다. 후자는 인증이 사실상 없는 상태라\n // 조용히 넘어가면 안 된다.\n if (!warnedMissingClientId) {\n warnedMissingClientId = true;\n warnAuth(\n \"clientId not provided — approval check is disabled (all users pass). \" +\n \"설정 누락이 아닌지 확인할 것.\"\n );\n }\n return true;\n }\n return approvedClients.includes(clientId) || approvedClients.includes(\"*\");\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lemondouble/lemon-auth",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/LemonDouble/lemon-auth"