@lemondouble/lemon-auth 0.7.2 → 0.8.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
 
@@ -237,7 +237,20 @@ import {
237
237
 
238
238
  ## Proxy — `@lemondouble/lemon-auth/proxy`
239
239
 
240
- Next.js 16의 `proxy.ts`에서 사용합니다. 보호 경로 접근 시 JWT 검증 + 만료 임박 토큰 자동 갱신을 처리합니다.
240
+ Next.js 16의 `proxy.ts`에서 사용합니다. **쿠키만 읽는 optimistic check** 만 합니다.
241
+
242
+ > **0.8.0 에서 동작이 바뀌었습니다.** 프록시는 더 이상 토큰을 갱신하지 않고
243
+ > JWT 서명도 검증하지 않습니다. 갱신은 `<SessionRestore />`(브라우저)가 맡고,
244
+ > 진짜 검증은 DAL(`getSession` / `requireClient`)이 합니다. 마이그레이션은
245
+ > [0.7.x → 0.8.0](#07x--080-마이그레이션) 참고.
246
+
247
+ Next.js 공식 인증 가이드의 권장 구조를 그대로 따릅니다.
248
+
249
+ > since Proxy runs on every route, including prefetched routes, it's important to
250
+ > only read the session from the cookie (optimistic checks), and avoid database
251
+ > checks to prevent performance issues.
252
+ >
253
+ > — [Next.js — Authentication](https://nextjs.org/docs/app/guides/authentication)
241
254
 
242
255
  ### `createAuthProxy(options?)`
243
256
 
@@ -246,11 +259,9 @@ Next.js 16의 `proxy.ts`에서 사용합니다. 보호 경로 접근 시 JWT 검
246
259
  import { createAuthProxy } from "@lemondouble/lemon-auth/proxy";
247
260
 
248
261
  export default createAuthProxy({
249
- publicPaths: ["/", "/about", "/api/public/*"],
262
+ publicPaths: ["/", "/about", "/api/public/*", "/auth/restore"],
250
263
  bypassPaths: ["/workbox-*"],
251
- clientId: process.env.CLIENT_ID,
252
264
  loginRedirectUrl: (request) => request.url,
253
- unapprovedRedirectUrl: "/pending-approval",
254
265
  });
255
266
 
256
267
  export const config = {
@@ -266,26 +277,33 @@ export const config = {
266
277
  |------|------|--------|------|
267
278
  | `publicPaths` | `string[]` | `[]` | 인증 없이 접근 가능한 경로. `*`로 끝나면 prefix 매칭 (예: `"/api/public/*"`) |
268
279
  | `bypassPaths` | `string[]` | `[]` | proxy가 refresh도 하지 않고 바로 통과시킬 추가 경로. 기본 PWA 경로(`/sw.js`, `/manifest.webmanifest` 등)는 항상 bypass |
269
- | `apiPaths` | `string[]` | `["/api/*"]` | 보호 API 경로. 인증 실패 시 redirect 대신 JSON 응답을 반환한다. disable하려면 `[]` |
270
- | `clientId` | `string` | `undefined` | 설정 시 `approved_clients`에 포함 여부를 체크 |
280
+ | `apiPaths` | `string[]` | `["/api/*"]` | 보호 API 경로. 인증 실패 시 redirect 대신 401 JSON을 반환한다. disable하려면 `[]` |
271
281
  | `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 동기화, 헤더 주입 등 앱별 로직에 사용 |
282
+ | `restorePath` | `string` | `"/auth/restore"` | `<SessionRestore />` 를 마운트한 경로. **`publicPaths` 에도 넣어야 한다** (안 넣으면 무한 루프) |
274
283
 
275
284
  #### 동작 방식
276
285
 
277
286
  **Bypass 경로** (기본 PWA/정적 리소스 + `bypassPaths`):
278
287
 
279
- 1. `/sw.js`, `/manifest.webmanifest`, `/manifest.json`, `/favicon.ico` 등은 인증/갱신 없이 항상 통과
288
+ 1. `/sw.js`, `/manifest.webmanifest`, `/favicon.ico` 등은 항상 통과
280
289
  2. 가능하면 `matcher`에서도 제외해 proxy 실행 자체를 피하는 것을 권장
281
290
 
291
+ **프리페치 요청**: 다른 요청과 똑같이 처리합니다. 통과시키면 만료된 토큰으로
292
+ 페이지가 렌더되고 거기서 나온 redirect 가 라우터 캐시에 굳어, 어느 링크를 눌러도
293
+ 같은 곳으로 튀게 됩니다. 반면 프록시가 돌려주는 `restorePath` 리다이렉트는 굳어도
294
+ 해가 없습니다 — 복구 경로는 막다른 길이 아니라 세션을 되살리고 `next` 로
295
+ 되돌려 보내므로 목적지도 보존됩니다.
296
+
282
297
  **보호 경로** (`publicPaths`에 해당하지 않는 경로):
283
298
 
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
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
+ `exp` 만 보고 **서명은 검증하지 않습니다.** 위조 토큰으로 이 검사는 통과할 수
305
+ 있으나 DAL 에서 걸립니다. 프록시의 판단은 "어느 화면으로 보낼까" 이지
306
+ "권한이 있는가" 가 아닙니다. 권한 판단은 반드시 DAL 에서 하십시오.
289
307
 
290
308
  `loginRedirectUrl`은 고정 URL 문자열과 요청별 resolver 함수를 모두 지원합니다.
291
309
 
@@ -301,10 +319,55 @@ createAuthProxy({
301
319
  });
302
320
  ```
303
321
 
304
- **공개 경로** (`publicPaths`에 해당하는 경로):
322
+ **공개 경로** (`publicPaths`에 해당하는 경로): 아무 판단 없이 통과합니다.
323
+ 만료된 세션은 루트 레이아웃의 `<AutoTokenRefresh />` 가 복구합니다.
324
+
325
+ ### 0.7.x → 0.8.0 마이그레이션
326
+
327
+ 프록시가 갱신을 하지 않게 되면서, 앱이 복구 경로를 마운트해야 합니다.
328
+
329
+ **1. `<SessionRestore />` 를 마운트하고 `publicPaths` 에 추가**
305
330
 
306
- 1. 토큰이 만료 임박이면 백그라운드로 갱신 (실패해도 통과)
307
- 2. 인증 여부와 무관하게 항상 통과
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
+ ```
351
+
352
+ `useSearchParams()` 를 쓰므로 `<Suspense>` 로 감싸야 빌드가 통과합니다.
353
+
354
+ **2. `<AutoTokenRefresh />` 를 루트 레이아웃으로**
355
+
356
+ 공개 경로에서 만료된 세션을 되살리는 안전망입니다. 특정 페이지에만 두면
357
+ 그 페이지를 거치지 않는 경로에는 복구 수단이 없습니다.
358
+
359
+ **3. 제거된 옵션 정리**
360
+
361
+ | 제거 | 대신 |
362
+ |---|---|
363
+ | `clientId` | DAL 의 `getSession({ clientId })` / `requireClient(clientId)` |
364
+ | `unapprovedRedirectUrl` | `requireClient(clientId, { unapprovedRedirectTo })` |
365
+ | `onAuthSuccess` | DAL. **미들웨어에서 DB 를 조회하지 마십시오** (아래) |
366
+
367
+ `onAuthSuccess` 는 DB 동기화를 유도하는 훅이었으나, 프록시는 프리페치를 포함해
368
+ 모든 요청에서 돌기 때문에 그 자리에서 DB 를 건드리면 안 됩니다. 사용자 동기화
369
+ 같은 작업은 DAL(`getUser()` 를 부르는 곳)로 옮기고 React `cache()` 로 요청당
370
+ 한 번만 돌게 하십시오.
308
371
 
309
372
  **API 경로** (`apiPaths` 매칭, 기본 `/api/*`):
310
373
 
@@ -426,10 +489,49 @@ export default async function RootLayout({ children }: { children: React.ReactNo
426
489
 
427
490
  | 상황 | user | AutoTokenRefresh |
428
491
  |------|------|-----------------|
429
- | 정상 요청 (proxy 실행됨) | 있음 | 아무것도 안 함 (skip) |
430
- | PWA 캐시 히트 + 갱신 시도 중 | 없음 | `fallback` 렌더 |
431
- | PWA 캐시 히트 + 유효한 `lemon_refresh_token` | 없음 | 갱신 → `router.refresh()` |
432
- | PWA 캐시 히트 + 만료된 `lemon_refresh_token` | 없음 | 갱신 실패 → `children` 렌더 |
492
+ | 정상 요청 | 있음 | 아무것도 안 함 (skip) |
493
+ | 공개 경로 + 갱신 시도 중 | 없음 | `fallback` 렌더 |
494
+ | 공개 경로 + 유효한 `lemon_refresh_token` | 없음 | 갱신 → `router.refresh()` |
495
+ | 공개 경로 + 만료된 `lemon_refresh_token` | 없음 | 갱신 실패 → `children` 렌더 |
496
+
497
+ **루트 레이아웃에 두십시오.** 특정 페이지에만 두면 그 페이지를 거치지 않는
498
+ 경로에는 복구 수단이 없습니다.
499
+
500
+ ### `<SessionRestore>`
501
+
502
+ 보호 경로에서 access token 이 만료됐을 때 프록시가 보내는 착지점입니다.
503
+ `refreshToken()` 을 부른 뒤 `next` 파라미터가 가리키는 곳으로 되돌립니다.
504
+
505
+ ```tsx
506
+ // app/auth/restore/page.tsx
507
+ import { Suspense } from "react";
508
+ import { SessionRestore } from "@lemondouble/lemon-auth/client";
509
+
510
+ export const dynamic = "force-dynamic";
511
+
512
+ export default function RestorePage() {
513
+ return (
514
+ <Suspense fallback={null}>
515
+ <SessionRestore fallback={<p>세션 복원 중...</p>} />
516
+ </Suspense>
517
+ );
518
+ }
519
+ ```
520
+
521
+ | prop | 타입 | 필수 | 설명 |
522
+ |------|------|------|------|
523
+ | `fallback` | `React.ReactNode` | **필수** | 갱신하는 동안 렌더할 UI |
524
+ | `failedRedirectUrl` | `string` | 선택 | 갱신 실패 시 보낼 곳. 기본은 로그인 |
525
+
526
+ - 이 경로를 프록시의 `publicPaths` 에 **반드시** 넣으십시오. 안 넣으면 프록시가
527
+ 복구 경로를 다시 복구 경로로 보내 무한 루프가 됩니다.
528
+ - `useSearchParams()` 를 쓰므로 `<Suspense>` 로 감싸야 빌드가 통과합니다.
529
+ - `next` 는 같은 출처의 경로만 허용합니다. 열린 리다이렉트가 되지 않도록
530
+ `//evil.com` 같은 값은 `/` 로 떨어뜨립니다.
531
+
532
+ **`<AutoTokenRefresh>` 와의 차이**: `AutoTokenRefresh` 는 공개 경로에서 화면을
533
+ 막지 않고 조용히 복구하는 안전망이고, `SessionRestore` 는 보호 경로 진입이
534
+ 막혔을 때 갱신하고 원래 목적지로 되돌리는 전용 착지점입니다. 둘 다 두십시오.
433
535
 
434
536
  ### `loginUrl(redirectUrl)`
435
537
 
@@ -544,7 +646,7 @@ interface UserProfile {
544
646
 
545
647
  새 Next.js 16 프로젝트에 인증을 추가하는 권장 예시입니다.
546
648
 
547
- ### 1. proxy.ts — 토큰 자동 갱신
649
+ ### 1. proxy.ts — optimistic check
548
650
 
549
651
  ```ts
550
652
  import {
@@ -553,12 +655,16 @@ import {
553
655
  } from "@lemondouble/lemon-auth/proxy";
554
656
 
555
657
  export default createAuthProxy({
556
- publicPaths: ["/", "/login", "/pending-approval", "/api/public/*"],
658
+ publicPaths: [
659
+ "/",
660
+ "/login",
661
+ "/pending-approval",
662
+ "/auth/restore",
663
+ "/api/public/*",
664
+ ],
557
665
  bypassPaths: ["/workbox-*"],
558
666
  apiPaths: DEFAULT_API_PATHS,
559
- clientId: process.env.CLIENT_ID,
560
667
  loginRedirectUrl: (request) => request.url,
561
- unapprovedRedirectUrl: "/pending-approval",
562
668
  });
563
669
 
564
670
  export const config = {
@@ -569,35 +675,48 @@ export const config = {
569
675
  ```
570
676
 
571
677
  `loginRedirectUrl: (request) => request.url`은 로그인 후 사용자가 원래 요청한 path/query로 돌아오게 합니다.
572
- `apiPaths`에 매칭되는 보호 API는 인증 실패 시 redirect 대신 `401/403` JSON 응답을 반환합니다.
678
+ `apiPaths`에 매칭되는 보호 API는 인증 실패 시 redirect 대신 `401` JSON 응답을 반환합니다.
573
679
  기본 PWA 파일(`/sw.js`, `/manifest.webmanifest` 등)은 항상 bypass되며, 앱에서 추가로 생성하는 workbox 파일은 `bypassPaths`에 넣을 수 있습니다.
574
680
 
575
- `onAuthSuccess`를 사용하면 인증 성공 후 DB 동기화, 헤더 주입 등 앱별 로직을 끼워넣을 수 있습니다:
681
+ **`/auth/restore` 를 `publicPaths` 에 넣는 것을 빠뜨리지 마십시오.** 프록시가
682
+ 복구 경로 자신을 다시 복구 경로로 보내 무한 루프가 됩니다.
683
+
684
+ 사용자 DB 동기화 같은 작업은 **프록시가 아니라 DAL 에서** 하십시오. 프록시는
685
+ 프리페치를 포함해 모든 요청에서 돌기 때문에, 그 자리에서 DB 를 건드리면 화면
686
+ 하나 여는 데 쿼리가 여러 번 나갑니다.
576
687
 
577
688
  ```ts
578
- import {
579
- createAuthProxy,
580
- DEFAULT_API_PATHS,
581
- } from "@lemondouble/lemon-auth/proxy";
689
+ // lib/dal.ts
690
+ import { cache } from "react";
691
+ import { getUser } from "@lemondouble/lemon-auth/server";
582
692
  import { upsertUser } from "@/lib/db";
583
693
 
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
- },
694
+ export const getCurrentUser = cache(async () => {
695
+ const user = await getUser();
696
+ if (!user) return null;
697
+ await upsertUser(user); // React cache() 덕에 요청당 한 번만 돈다
698
+ return user;
597
699
  });
598
700
  ```
599
701
 
600
- ### 2. app/layout.tsx — AuthProvider + AutoTokenRefresh
702
+ ### 2. app/auth/restore/page.tsx — 세션 복구 착지점
703
+
704
+ ```tsx
705
+ import { Suspense } from "react";
706
+ import { SessionRestore } from "@lemondouble/lemon-auth/client";
707
+
708
+ export const dynamic = "force-dynamic";
709
+
710
+ export default function RestorePage() {
711
+ return (
712
+ <Suspense fallback={null}>
713
+ <SessionRestore fallback={<p>세션 복원 중...</p>} />
714
+ </Suspense>
715
+ );
716
+ }
717
+ ```
718
+
719
+ ### 3. app/layout.tsx — AuthProvider + AutoTokenRefresh
601
720
 
602
721
  ```tsx
603
722
  import { getUser } from "@lemondouble/lemon-auth/server";
@@ -618,7 +737,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
618
737
  }
619
738
  ```
620
739
 
621
- ### 3. app/page.tsx — 공개 페이지 (로그인 버튼)
740
+ ### 4. app/page.tsx — 공개 페이지 (로그인 버튼)
622
741
 
623
742
  ```tsx
624
743
  import { getUser } from "@lemondouble/lemon-auth/server";
@@ -642,7 +761,7 @@ export function LoginButton() {
642
761
  }
643
762
  ```
644
763
 
645
- ### 4. app/dashboard/page.tsx — 보호 페이지
764
+ ### 5. app/dashboard/page.tsx — 보호 페이지
646
765
 
647
766
  ```tsx
648
767
  import { requireAuth } from "@lemondouble/lemon-auth/server";
@@ -653,7 +772,7 @@ export default async function Dashboard() {
653
772
  }
654
773
  ```
655
774
 
656
- ### 5. 로그아웃
775
+ ### 6. 로그아웃
657
776
 
658
777
  ```tsx
659
778
  "use client";
@@ -18,9 +18,34 @@ declare function AutoTokenRefresh({ children, fallback, }: {
18
18
  fallback: React.ReactNode;
19
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
20
 
21
+ /**
22
+ * 토큰을 갱신하는 유일한 주체.
23
+ *
24
+ * 프록시는 쿠키만 읽고, access 가 만료됐는데 refresh 가 남아 있으면 이 경로로
25
+ * 보낸다. 여기서 브라우저가 직접 갱신한 뒤 원래 가려던 곳으로 돌려보낸다.
26
+ *
27
+ * 갱신을 서버(미들웨어)가 아니라 브라우저가 하는 이유는 두 가지다.
28
+ *
29
+ * 1. 미들웨어는 요청마다 별도 인스턴스로 떠서 서로를 모른다. 프리페치가
30
+ * 깔리면 같은 refresh token 으로 갱신이 동시에 나가고, 회전 경쟁이 난다.
31
+ * 브라우저는 JS 컨텍스트가 하나라 이 문제가 없다.
32
+ * 2. 브라우저 요청에는 진짜 UA 와 WAF 통과 쿠키가 실린다. 서버간 fetch 는
33
+ * UA 가 런타임 기본값이라 봇 방어에 걸릴 수 있다.
34
+ *
35
+ * 앱은 이 컴포넌트를 프록시의 `restorePath`(기본 `/auth/restore`)에 마운트하고,
36
+ * 그 경로를 `publicPaths` 에 넣어야 한다. 안 그러면 프록시가 복구 경로 자신을
37
+ * 다시 복구 경로로 보내 무한 루프가 된다.
38
+ */
39
+ declare function SessionRestore({ fallback, failedRedirectUrl, }: {
40
+ /** 갱신하는 동안 보여줄 것. */
41
+ fallback: React.ReactNode;
42
+ /** 갱신 실패 시 보낼 곳. 기본은 로그인. */
43
+ failedRedirectUrl?: string;
44
+ }): react.ReactNode;
45
+
21
46
  declare function loginUrl(redirectUrl: string): string;
22
47
  declare function profileUrl(redirectUrl?: string): string;
23
48
  declare function logout(): Promise<boolean>;
24
49
  declare function refreshToken(): Promise<boolean>;
25
50
 
26
- export { AuthProvider, AutoTokenRefresh, LemonUser, loginUrl, logout, profileUrl, refreshToken, useAuth };
51
+ export { AuthProvider, AutoTokenRefresh, LemonUser, SessionRestore, loginUrl, logout, profileUrl, refreshToken, useAuth };
@@ -156,9 +156,51 @@ function AutoTokenRefresh({
156
156
  if (status === "loading") return fallback ?? null;
157
157
  return children ?? null;
158
158
  }
159
+
160
+ // 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();
169
+ const searchParams = useSearchParams();
170
+ const [failed, setFailed] = useState2(false);
171
+ useEffect2(() => {
172
+ let ignore = false;
173
+ const next = safeNextPath(searchParams.get("next"));
174
+ if (isAuthenticated) {
175
+ router.replace(next);
176
+ return;
177
+ }
178
+ refreshToken().then((ok) => {
179
+ if (ignore) return;
180
+ if (ok) {
181
+ router.replace(next);
182
+ return;
183
+ }
184
+ setFailed(true);
185
+ window.location.href = failedRedirectUrl ?? loginUrl(window.location.origin);
186
+ });
187
+ return () => {
188
+ ignore = true;
189
+ };
190
+ }, [isAuthenticated, router, searchParams, failedRedirectUrl]);
191
+ if (failed) return null;
192
+ return fallback;
193
+ }
194
+ function safeNextPath(next) {
195
+ if (!next) return "/";
196
+ if (!next.startsWith("/")) return "/";
197
+ if (next.startsWith("//")) return "/";
198
+ return next;
199
+ }
159
200
  export {
160
201
  AuthProvider,
161
202
  AutoTokenRefresh,
203
+ SessionRestore,
162
204
  loginUrl,
163
205
  logout,
164
206
  profileUrl,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/client/context.tsx","../../src/client/auto-token-refresh.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 { 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"],"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;","names":[]}
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":"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","names":["useEffect","useState","useRouter","useRouter","useState","useEffect"]}
@@ -1,10 +1,9 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
- import { A as AccessTokenClaims } from '../types-B5uRY2Si.js';
3
- export { a as LemonUser } from '../types-B5uRY2Si.js';
4
- import 'jose';
5
2
 
6
3
  declare const DEFAULT_AUTH_BYPASS_PATHS: string[];
7
4
  declare const DEFAULT_API_PATHS: string[];
5
+ /** 앱이 <SessionRestore /> 를 마운트해야 하는 기본 경로. */
6
+ declare const DEFAULT_RESTORE_PATH = "/auth/restore";
8
7
  declare const PROXY_AUTH_ERROR: {
9
8
  readonly UNAUTHORIZED: "UNAUTHORIZED";
10
9
  readonly FORBIDDEN: "FORBIDDEN";
@@ -12,14 +11,16 @@ declare const PROXY_AUTH_ERROR: {
12
11
  type ProxyAuthErrorCode = (typeof PROXY_AUTH_ERROR)[keyof typeof PROXY_AUTH_ERROR];
13
12
  type LoginRedirectUrl = string | ((request: NextRequest) => string);
14
13
  interface AuthProxyOptions {
14
+ /** 로그인 없이 열어둘 경로. 여기서는 아무 판단도 하지 않고 통과시킨다. */
15
15
  publicPaths?: string[];
16
+ /** 프록시 자체를 건너뛸 경로. 정적 파일 등. */
16
17
  bypassPaths?: string[];
18
+ /** 리다이렉트 대신 401 JSON 을 돌려줄 경로. 기본 `/api/*`. */
17
19
  apiPaths?: string[];
18
- clientId?: string;
19
20
  loginRedirectUrl?: LoginRedirectUrl;
20
- unapprovedRedirectUrl?: LoginRedirectUrl;
21
- onAuthSuccess?: (claims: AccessTokenClaims, request: NextRequest, response: NextResponse) => Promise<NextResponse> | NextResponse;
21
+ /** <SessionRestore /> 를 마운트한 경로. 기본 `/auth/restore`. */
22
+ restorePath?: string;
22
23
  }
23
24
  declare function createAuthProxy(options?: AuthProxyOptions): (request: NextRequest) => Promise<NextResponse>;
24
25
 
25
- export { AccessTokenClaims, type AuthProxyOptions, DEFAULT_API_PATHS, DEFAULT_AUTH_BYPASS_PATHS, type LoginRedirectUrl, PROXY_AUTH_ERROR, type ProxyAuthErrorCode, createAuthProxy };
26
+ export { type AuthProxyOptions, DEFAULT_API_PATHS, DEFAULT_AUTH_BYPASS_PATHS, DEFAULT_RESTORE_PATH, type LoginRedirectUrl, PROXY_AUTH_ERROR, type ProxyAuthErrorCode, createAuthProxy };
@@ -1,23 +1,12 @@
1
- import {
2
- errorCode,
3
- isAccessTokenClaims,
4
- isApprovedClient,
5
- warnAuth
6
- } from "../chunk-K4U4PHX6.js";
7
1
  import {
8
2
  ACCESS_TOKEN_COOKIE,
9
- AUTH_SERVER_URL,
10
- DEVICE_ID_COOKIE,
11
- JWKS_URL,
12
3
  LOGIN_URL,
13
4
  REFRESH_TOKEN_COOKIE,
14
- REFRESH_URL,
15
- getMockAccessTokenClaims
5
+ isMockAuthEnabled
16
6
  } from "../chunk-OX5MDVSR.js";
17
7
 
18
8
  // src/proxy/index.ts
19
9
  import { NextResponse } from "next/server";
20
- import { createRemoteJWKSet, jwtVerify } from "jose";
21
10
  var REFRESH_BUFFER_SECONDS = 60;
22
11
  var DEFAULT_AUTH_BYPASS_PATHS = [
23
12
  "/sw.js",
@@ -34,144 +23,49 @@ var DEFAULT_AUTH_BYPASS_PATHS = [
34
23
  "/icons/*"
35
24
  ];
36
25
  var DEFAULT_API_PATHS = ["/api/*"];
26
+ var DEFAULT_RESTORE_PATH = "/auth/restore";
37
27
  var PROXY_AUTH_ERROR = {
38
28
  UNAUTHORIZED: "UNAUTHORIZED",
39
29
  FORBIDDEN: "FORBIDDEN"
40
30
  };
41
- var jwks = createRemoteJWKSet(new URL(JWKS_URL));
42
31
  function createAuthProxy(options = {}) {
43
32
  const {
44
33
  publicPaths = [],
45
34
  bypassPaths = [],
46
35
  apiPaths = DEFAULT_API_PATHS,
47
- clientId,
48
36
  loginRedirectUrl,
49
- unapprovedRedirectUrl,
50
- onAuthSuccess
37
+ restorePath = DEFAULT_RESTORE_PATH
51
38
  } = options;
52
- const effectiveBypassPaths = [
53
- ...DEFAULT_AUTH_BYPASS_PATHS,
54
- ...bypassPaths
55
- ];
39
+ const effectiveBypassPaths = [...DEFAULT_AUTH_BYPASS_PATHS, ...bypassPaths];
56
40
  return async function proxy(request) {
57
41
  const { pathname } = request.nextUrl;
58
42
  if (isPublicPath(pathname, effectiveBypassPaths)) {
59
43
  return NextResponse.next();
60
44
  }
61
45
  if (isPublicPath(pathname, publicPaths)) {
62
- return maybeRefreshAndContinue(request);
46
+ return NextResponse.next();
63
47
  }
64
- const isApiRequest = isPublicPath(pathname, apiPaths);
65
- const mockClaims = getMockAccessTokenClaims();
66
- if (mockClaims) {
67
- return handleVerifiedClaims(
68
- mockClaims,
69
- request,
70
- isApiRequest,
71
- clientId,
72
- unapprovedRedirectUrl,
73
- onAuthSuccess,
74
- NextResponse.next()
75
- );
48
+ if (isMockAuthEnabled()) {
49
+ return NextResponse.next();
76
50
  }
77
51
  const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;
78
- const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;
79
- const deviceId = request.cookies.get(DEVICE_ID_COOKIE)?.value;
80
52
  if (accessToken && isTokenFresh(accessToken)) {
81
- const claims = await verifyToken(accessToken);
82
- if (claims) {
83
- return handleVerifiedClaims(
84
- claims,
85
- request,
86
- isApiRequest,
87
- clientId,
88
- unapprovedRedirectUrl,
89
- onAuthSuccess,
90
- NextResponse.next()
91
- );
92
- }
53
+ return NextResponse.next();
93
54
  }
94
- if (refreshToken) {
95
- const result = await tryRefresh(refreshToken, deviceId);
96
- if (result) {
97
- const claims = await verifyToken(result.newAccessToken);
98
- if (claims) {
99
- const response = nextWithRefreshedCookies(
100
- request,
101
- result.setCookieHeaders
102
- );
103
- return handleVerifiedClaims(
104
- claims,
105
- request,
106
- isApiRequest,
107
- clientId,
108
- unapprovedRedirectUrl,
109
- onAuthSuccess,
110
- response
111
- );
112
- }
113
- }
55
+ const isApiRequest = isPublicPath(pathname, apiPaths);
56
+ if (isApiRequest) {
57
+ return NextResponse.json(
58
+ { code: PROXY_AUTH_ERROR.UNAUTHORIZED },
59
+ { status: 401 }
60
+ );
114
61
  }
115
- return isApiRequest ? unauthorizedJson() : redirectToLogin(request, loginRedirectUrl);
116
- };
117
- }
118
- async function handleVerifiedClaims(claims, request, isApiRequest, clientId, unapprovedRedirectUrl, onAuthSuccess, response) {
119
- if (!isApprovedClient(claims.approved_clients, clientId)) {
120
- return isApiRequest ? forbiddenJson() : redirectToUnapproved(request, unapprovedRedirectUrl);
121
- }
122
- return onAuthSuccess ? await onAuthSuccess(claims, request, response) : response;
123
- }
124
- function unauthorizedJson() {
125
- return NextResponse.json(
126
- { code: PROXY_AUTH_ERROR.UNAUTHORIZED },
127
- { status: 401 }
128
- );
129
- }
130
- function forbiddenJson() {
131
- return NextResponse.json(
132
- { code: PROXY_AUTH_ERROR.FORBIDDEN },
133
- { status: 403 }
134
- );
135
- }
136
- async function maybeRefreshAndContinue(request) {
137
- const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;
138
- const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;
139
- const deviceId = request.cookies.get(DEVICE_ID_COOKIE)?.value;
140
- if (accessToken && isTokenFresh(accessToken)) {
141
- return NextResponse.next();
142
- }
143
- if (refreshToken) {
144
- const result = await tryRefresh(refreshToken, deviceId);
145
- if (result) {
146
- return nextWithRefreshedCookies(request, result.setCookieHeaders);
62
+ if (request.cookies.get(REFRESH_TOKEN_COOKIE)?.value) {
63
+ const restore = new URL(restorePath, request.url);
64
+ restore.searchParams.set("next", pathname + request.nextUrl.search);
65
+ return NextResponse.redirect(restore);
147
66
  }
148
- warnAuth("refresh failed on public path, continuing anonymously", {
149
- path: request.nextUrl.pathname
150
- });
151
- }
152
- return NextResponse.next();
153
- }
154
- function nextWithRefreshedCookies(request, setCookieHeaders) {
155
- const merged = /* @__PURE__ */ new Map();
156
- for (const { name, value } of request.cookies.getAll()) {
157
- merged.set(name, value);
158
- }
159
- for (const header of setCookieHeaders) {
160
- const pair = header.split(";")[0] ?? "";
161
- const separator = pair.indexOf("=");
162
- if (separator <= 0) continue;
163
- merged.set(pair.slice(0, separator).trim(), pair.slice(separator + 1).trim());
164
- }
165
- const requestHeaders = new Headers(request.headers);
166
- requestHeaders.set(
167
- "cookie",
168
- Array.from(merged, ([name, value]) => `${name}=${value}`).join("; ")
169
- );
170
- const response = NextResponse.next({ request: { headers: requestHeaders } });
171
- for (const header of setCookieHeaders) {
172
- response.headers.append("Set-Cookie", header);
173
- }
174
- return response;
67
+ return redirectToLogin(request, loginRedirectUrl);
68
+ };
175
69
  }
176
70
  function isTokenFresh(token) {
177
71
  try {
@@ -185,65 +79,6 @@ function isTokenFresh(token) {
185
79
  return false;
186
80
  }
187
81
  }
188
- async function verifyToken(token) {
189
- try {
190
- const { payload } = await jwtVerify(token, jwks, {
191
- issuer: AUTH_SERVER_URL
192
- });
193
- if (isAccessTokenClaims(payload)) return payload;
194
- warnAuth("access token claims shape mismatch");
195
- return null;
196
- } catch (error) {
197
- const code = errorCode(error);
198
- if (code !== "ERR_JWT_EXPIRED") {
199
- warnAuth("access token verification failed", { code });
200
- }
201
- return null;
202
- }
203
- }
204
- async function tryRefresh(refreshToken, deviceId) {
205
- try {
206
- const res = await fetch(REFRESH_URL, {
207
- method: "POST",
208
- headers: { Cookie: buildRefreshCookieHeader(refreshToken, deviceId) }
209
- });
210
- if (!res.ok) {
211
- warnAuth("token refresh rejected", {
212
- status: res.status,
213
- server: res.headers.get("server"),
214
- cfRay: res.headers.get("cf-ray"),
215
- cfMitigated: res.headers.get("cf-mitigated")
216
- });
217
- return null;
218
- }
219
- const setCookieHeaders = res.headers.getSetCookie();
220
- const newAccessToken = extractAccessToken(setCookieHeaders);
221
- if (!newAccessToken) {
222
- warnAuth("refresh succeeded but no access token cookie", {
223
- cookieNames: setCookieHeaders.map((h) => h.split("=")[0])
224
- });
225
- return null;
226
- }
227
- return { newAccessToken, setCookieHeaders };
228
- } catch (error) {
229
- warnAuth("token refresh request failed", { code: errorCode(error) });
230
- return null;
231
- }
232
- }
233
- function buildRefreshCookieHeader(refreshToken, deviceId) {
234
- const cookies = [`${REFRESH_TOKEN_COOKIE}=${refreshToken}`];
235
- if (deviceId) {
236
- cookies.push(`${DEVICE_ID_COOKIE}=${deviceId}`);
237
- }
238
- return cookies.join("; ");
239
- }
240
- function extractAccessToken(setCookieHeaders) {
241
- for (const header of setCookieHeaders) {
242
- const match = header.match(new RegExp(`^${ACCESS_TOKEN_COOKIE}=([^;]+)`));
243
- if (match) return match[1];
244
- }
245
- return null;
246
- }
247
82
  function isPublicPath(pathname, publicPaths) {
248
83
  return publicPaths.some((p) => {
249
84
  if (p.endsWith("*")) {
@@ -253,32 +88,17 @@ function isPublicPath(pathname, publicPaths) {
253
88
  });
254
89
  }
255
90
  function redirectToLogin(request, loginRedirectUrl) {
256
- const redirectUrl = resolveLoginRedirectUrl(request, loginRedirectUrl);
91
+ const redirectUrl = typeof loginRedirectUrl === "function" ? loginRedirectUrl(request) : loginRedirectUrl;
257
92
  if (redirectUrl) {
258
93
  const url = `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;
259
94
  return NextResponse.redirect(new URL(url, request.url));
260
95
  }
261
96
  return NextResponse.redirect(new URL("/", request.url));
262
97
  }
263
- function redirectToUnapproved(request, unapprovedRedirectUrl) {
264
- const redirectUrl = resolveLoginRedirectUrl(request, unapprovedRedirectUrl);
265
- if (redirectUrl) {
266
- return NextResponse.redirect(new URL(redirectUrl, request.url));
267
- }
268
- const errorUrl = new URL(`${AUTH_SERVER_URL}/error`);
269
- errorUrl.searchParams.set("code", PROXY_AUTH_ERROR.FORBIDDEN);
270
- errorUrl.searchParams.set("from", request.url);
271
- return NextResponse.redirect(errorUrl);
272
- }
273
- function resolveLoginRedirectUrl(request, loginRedirectUrl) {
274
- if (typeof loginRedirectUrl === "function") {
275
- return loginRedirectUrl(request);
276
- }
277
- return loginRedirectUrl;
278
- }
279
98
  export {
280
99
  DEFAULT_API_PATHS,
281
100
  DEFAULT_AUTH_BYPASS_PATHS,
101
+ DEFAULT_RESTORE_PATH,
282
102
  PROXY_AUTH_ERROR,
283
103
  createAuthProxy
284
104
  };
@@ -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 DEVICE_ID_COOKIE,\n AUTH_SERVER_URL,\n REFRESH_URL,\n JWKS_URL,\n LOGIN_URL,\n} from \"../constants.js\";\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport type { AccessTokenClaims } from \"../types.js\";\nimport { isAccessTokenClaims } from \"../claims.js\";\nimport { isApprovedClient } from \"../approval.js\";\nimport { getMockAccessTokenClaims } from \"../mock.js\";\nimport { warnAuth, errorCode } from \"../log.js\";\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\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\nconst jwks = createRemoteJWKSet(new URL(JWKS_URL));\n\nexport type LoginRedirectUrl = string | ((request: NextRequest) => string);\n\nexport interface AuthProxyOptions {\n publicPaths?: string[];\n bypassPaths?: string[];\n apiPaths?: string[];\n clientId?: string;\n loginRedirectUrl?: LoginRedirectUrl;\n unapprovedRedirectUrl?: LoginRedirectUrl;\n onAuthSuccess?: (\n claims: AccessTokenClaims,\n request: NextRequest,\n response: NextResponse\n ) => Promise<NextResponse> | NextResponse;\n}\n\nexport function createAuthProxy(options: AuthProxyOptions = {}) {\n const {\n publicPaths = [],\n bypassPaths = [],\n apiPaths = DEFAULT_API_PATHS,\n clientId,\n loginRedirectUrl,\n unapprovedRedirectUrl,\n onAuthSuccess,\n } = options;\n const effectiveBypassPaths = [\n ...DEFAULT_AUTH_BYPASS_PATHS,\n ...bypassPaths,\n ];\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 maybeRefreshAndContinue(request);\n }\n\n const isApiRequest = isPublicPath(pathname, apiPaths);\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) {\n return handleVerifiedClaims(\n mockClaims,\n request,\n isApiRequest,\n clientId,\n unapprovedRedirectUrl,\n onAuthSuccess,\n NextResponse.next()\n );\n }\n\n const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;\n const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;\n const deviceId = request.cookies.get(DEVICE_ID_COOKIE)?.value;\n\n if (accessToken && isTokenFresh(accessToken)) {\n const claims = await verifyToken(accessToken);\n if (claims) {\n return handleVerifiedClaims(\n claims,\n request,\n isApiRequest,\n clientId,\n unapprovedRedirectUrl,\n onAuthSuccess,\n NextResponse.next()\n );\n }\n }\n\n if (refreshToken) {\n const result = await tryRefresh(refreshToken, deviceId);\n if (result) {\n const claims = await verifyToken(result.newAccessToken);\n if (claims) {\n const response = nextWithRefreshedCookies(\n request,\n result.setCookieHeaders\n );\n return handleVerifiedClaims(\n claims,\n request,\n isApiRequest,\n clientId,\n unapprovedRedirectUrl,\n onAuthSuccess,\n response\n );\n }\n }\n }\n\n return isApiRequest\n ? unauthorizedJson()\n : redirectToLogin(request, loginRedirectUrl);\n };\n}\n\nasync function handleVerifiedClaims(\n claims: AccessTokenClaims,\n request: NextRequest,\n isApiRequest: boolean,\n clientId: string | undefined,\n unapprovedRedirectUrl: LoginRedirectUrl | undefined,\n onAuthSuccess: AuthProxyOptions[\"onAuthSuccess\"],\n response: NextResponse\n): Promise<NextResponse> {\n if (!isApprovedClient(claims.approved_clients, clientId)) {\n return isApiRequest\n ? forbiddenJson()\n : redirectToUnapproved(request, unapprovedRedirectUrl);\n }\n\n return onAuthSuccess\n ? await onAuthSuccess(claims, request, response)\n : response;\n}\n\nfunction unauthorizedJson(): NextResponse {\n return NextResponse.json(\n { code: PROXY_AUTH_ERROR.UNAUTHORIZED },\n { status: 401 }\n );\n}\n\nfunction forbiddenJson(): NextResponse {\n return NextResponse.json(\n { code: PROXY_AUTH_ERROR.FORBIDDEN },\n { status: 403 }\n );\n}\n\nasync function maybeRefreshAndContinue(\n request: NextRequest\n): Promise<NextResponse> {\n const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;\n const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;\n const deviceId = request.cookies.get(DEVICE_ID_COOKIE)?.value;\n\n if (accessToken && isTokenFresh(accessToken)) {\n return NextResponse.next();\n }\n\n if (refreshToken) {\n const result = await tryRefresh(refreshToken, deviceId);\n if (result) {\n return nextWithRefreshedCookies(request, result.setCookieHeaders);\n }\n // 공개 경로라 요청은 그대로 통과시킨다. 다만 이 실패는 밖에서 보이지 않는다\n // — 응답이 성공했을 때와 바이트 단위로 같기 때문이다. 유일한 증상은\n // \"로그인 상태로 보여야 할 화면이 익명으로 렌더되는 것\" 이고, 그마저도\n // 클라이언트 갱신(AutoTokenRefresh)이 있으면 가려진다. 그래서 여기서\n // 한 줄이라도 남겨야 한다. tryRefresh 가 이미 사유를 찍었다.\n warnAuth(\"refresh failed on public path, continuing anonymously\", {\n path: request.nextUrl.pathname,\n });\n }\n\n return NextResponse.next();\n}\n\n/**\n * 갱신된 토큰을 이 요청의 cookie 헤더에도 반영한 응답을 만든다.\n *\n * Set-Cookie 는 브라우저에만 적용된다. 그것만 붙이면 지금 이 요청을 처리하는\n * 서버 컴포넌트는 여전히 만료된 access token 을 읽고, 갱신에 성공했는데도\n * 그 요청은 로그아웃으로 판정된다. access token 이 만료된 뒤 첫 요청마다\n * 발생하며, PWA 콜드 스타트처럼 항상 만료 상태에서 시작하는 경우 매번 겪는다.\n *\n * Next.js 는 미들웨어가 넘긴 요청 헤더를 다운스트림에 그대로 전달하므로\n * cookie 헤더를 새 토큰으로 바꿔 끼워 같은 요청에서 인증이 보이게 한다.\n * (response.headers.append 는 NextResponse 의 쿠키 프록시를 우회하기 때문에\n * Next 의 x-middleware-set-cookie 병합 경로도 타지 않는다)\n */\nfunction nextWithRefreshedCookies(\n request: NextRequest,\n setCookieHeaders: string[]\n): NextResponse {\n const merged = new Map<string, string>();\n for (const { name, value } of request.cookies.getAll()) {\n merged.set(name, value);\n }\n\n // auth 서버는 도메인마다 같은 쿠키를 한 번씩 보내므로 이름 기준으로 덮어쓴다.\n for (const header of setCookieHeaders) {\n const pair = header.split(\";\")[0] ?? \"\";\n const separator = pair.indexOf(\"=\");\n if (separator <= 0) continue;\n merged.set(pair.slice(0, separator).trim(), pair.slice(separator + 1).trim());\n }\n\n const requestHeaders = new Headers(request.headers);\n requestHeaders.set(\n \"cookie\",\n Array.from(merged, ([name, value]) => `${name}=${value}`).join(\"; \")\n );\n\n const response = NextResponse.next({ request: { headers: requestHeaders } });\n for (const header of setCookieHeaders) {\n response.headers.append(\"Set-Cookie\", header);\n }\n return response;\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 );\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\nasync function verifyToken(\n token: string\n): 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 // 서명은 유효한데 클레임 모양이 다르다. auth 서버와 버전이 어긋났을 때 난다.\n warnAuth(\"access token claims shape mismatch\");\n return null;\n } catch (error) {\n // 정상 만료(ERR_JWT_EXPIRED)는 갱신으로 이어지므로 소음이다. 나머지는 남긴다.\n // 특히 ERR_JWKS_NO_MATCHING_KEY 는 키 로테이션이나 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\ninterface RefreshSuccess {\n newAccessToken: string;\n setCookieHeaders: string[];\n}\n\nasync function tryRefresh(\n refreshToken: string,\n deviceId?: string\n): Promise<RefreshSuccess | null> {\n try {\n const res = await fetch(REFRESH_URL, {\n method: \"POST\",\n headers: { Cookie: buildRefreshCookieHeader(refreshToken, deviceId) },\n });\n\n if (!res.ok) {\n // 이 요청은 브라우저가 아니라 서버에서 나간다. UA 는 런타임 기본값이고\n // cf_clearance 같은 챌린지 통과 쿠키도 없으므로, auth 서버 앞에 WAF 나\n // 봇 방어가 있으면 origin 에 닿기 전에 잘린다. 2026-07-19 에 실제로\n // Cloudflare Bot Fight Mode 가 403 + cf-mitigated: challenge 로 막았다.\n // 그때 auth 서버 로그에는 아무것도 남지 않았다 — 도달을 못 했으니까.\n // 그래서 상태 코드와 함께 edge 가 남긴 힌트를 같이 찍는다.\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 null;\n }\n\n const setCookieHeaders = res.headers.getSetCookie();\n const newAccessToken = extractAccessToken(setCookieHeaders);\n if (!newAccessToken) {\n // 200 인데 access token 이 없다. 쿠키 이름이 어긋났거나 중간 프록시가\n // Set-Cookie 를 떨궜다. 값은 절대 찍지 않고 이름만 남긴다.\n warnAuth(\"refresh succeeded but no access token cookie\", {\n cookieNames: setCookieHeaders.map((h) => h.split(\"=\")[0]),\n });\n return null;\n }\n\n return { newAccessToken, setCookieHeaders };\n } catch (error) {\n // DNS · TLS · 타임아웃. auth 서버가 내려갔거나 경로가 막힌 경우다.\n warnAuth(\"token refresh request failed\", { code: errorCode(error) });\n return null;\n }\n}\n\nfunction buildRefreshCookieHeader(\n refreshToken: string,\n deviceId?: string\n): string {\n const cookies = [`${REFRESH_TOKEN_COOKIE}=${refreshToken}`];\n if (deviceId) {\n cookies.push(`${DEVICE_ID_COOKIE}=${deviceId}`);\n }\n return cookies.join(\"; \");\n}\n\nfunction extractAccessToken(setCookieHeaders: string[]): string | null {\n for (const header of setCookieHeaders) {\n const match = header.match(new RegExp(`^${ACCESS_TOKEN_COOKIE}=([^;]+)`));\n if (match) return match[1];\n }\n return null;\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\n): NextResponse {\n const redirectUrl = resolveLoginRedirectUrl(request, loginRedirectUrl);\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\nfunction redirectToUnapproved(\n request: NextRequest,\n unapprovedRedirectUrl?: LoginRedirectUrl\n): NextResponse {\n const redirectUrl = resolveLoginRedirectUrl(request, unapprovedRedirectUrl);\n if (redirectUrl) {\n return NextResponse.redirect(new URL(redirectUrl, request.url));\n }\n const errorUrl = new URL(`${AUTH_SERVER_URL}/error`);\n errorUrl.searchParams.set(\"code\", PROXY_AUTH_ERROR.FORBIDDEN);\n errorUrl.searchParams.set(\"from\", request.url);\n return NextResponse.redirect(errorUrl);\n}\n\nfunction resolveLoginRedirectUrl(\n request: NextRequest,\n loginRedirectUrl?: LoginRedirectUrl\n): string | undefined {\n if (typeof loginRedirectUrl === \"function\") {\n return loginRedirectUrl(request);\n }\n return loginRedirectUrl;\n}\n\nexport type { AccessTokenClaims } from \"../types.js\";\nexport type { LemonUser } from \"../types.js\";\n"],"mappings":"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","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 * 프록시는 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,9 +1,3 @@
1
- import {
2
- errorCode,
3
- isAccessTokenClaims,
4
- isApprovedClient,
5
- warnAuth
6
- } from "../chunk-K4U4PHX6.js";
7
1
  import {
8
2
  ACCESS_TOKEN_COOKIE,
9
3
  AUTH_SERVER_URL,
@@ -22,6 +16,35 @@ import {
22
16
  // src/server/verify.ts
23
17
  import { createRemoteJWKSet, jwtVerify } from "jose";
24
18
  import { cookies } from "next/headers";
19
+
20
+ // src/claims.ts
21
+ function isAccessTokenClaims(payload) {
22
+ return payload.token_type === "access" && typeof payload.sub === "string" && typeof payload.nickname === "string" && typeof payload.profile_image_url === "string" && (payload.role === "user" || payload.role === "admin") && Array.isArray(payload.approved_clients) && payload.approved_clients.every((clientId) => typeof clientId === "string");
23
+ }
24
+
25
+ // src/log.ts
26
+ var PREFIX = "[lemon-auth]";
27
+ function warnAuth(event, detail) {
28
+ if (detail === void 0) {
29
+ console.warn(`${PREFIX} ${event}`);
30
+ return;
31
+ }
32
+ console.warn(`${PREFIX} ${event}`, JSON.stringify(detail));
33
+ }
34
+ function errorCode(error) {
35
+ if (typeof error === "object" && error !== null) {
36
+ const { code, cause } = error;
37
+ if (typeof code === "string") return code;
38
+ if (typeof cause === "object" && cause !== null) {
39
+ const causeCode = cause.code;
40
+ if (typeof causeCode === "string") return causeCode;
41
+ }
42
+ }
43
+ if (error instanceof Error) return error.name;
44
+ return "unknown";
45
+ }
46
+
47
+ // src/server/verify.ts
25
48
  var jwks = createRemoteJWKSet(new URL(JWKS_URL));
26
49
  async function verify(token) {
27
50
  try {
@@ -56,6 +79,23 @@ async function verifyAccessTokenString(token) {
56
79
  // src/server/user.ts
57
80
  import { cache } from "react";
58
81
  import { redirect } from "next/navigation";
82
+
83
+ // src/approval.ts
84
+ var warnedMissingClientId = false;
85
+ function isApprovedClient(approvedClients, clientId) {
86
+ if (!clientId) {
87
+ if (!warnedMissingClientId) {
88
+ warnedMissingClientId = true;
89
+ warnAuth(
90
+ "clientId not provided \u2014 approval check is disabled (all users pass). \uC124\uC815 \uB204\uB77D\uC774 \uC544\uB2CC\uC9C0 \uD655\uC778\uD560 \uAC83."
91
+ );
92
+ }
93
+ return true;
94
+ }
95
+ return approvedClients.includes(clientId) || approvedClients.includes("*");
96
+ }
97
+
98
+ // src/server/user.ts
59
99
  function claimsToUser(claims) {
60
100
  return {
61
101
  uid: claims.sub,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/verify.ts","../../src/server/user.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 { 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 {\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","../../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"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lemondouble/lemon-auth",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/LemonDouble/lemon-auth"
@@ -24,13 +24,6 @@
24
24
  "files": [
25
25
  "dist"
26
26
  ],
27
- "scripts": {
28
- "build": "tsup",
29
- "dev": "tsup --watch",
30
- "typecheck": "tsc --noEmit",
31
- "prepublishOnly": "pnpm run build"
32
- },
33
- "packageManager": "pnpm@10.25.0",
34
27
  "peerDependencies": {
35
28
  "next": ">=16.0.0",
36
29
  "react": ">=19.0.0"
@@ -51,5 +44,10 @@
51
44
  "@types/react": "^19.2.14",
52
45
  "tsup": "^8.5.1",
53
46
  "typescript": "^6.0.3"
47
+ },
48
+ "scripts": {
49
+ "build": "tsup",
50
+ "dev": "tsup --watch",
51
+ "typecheck": "tsc --noEmit"
54
52
  }
55
- }
53
+ }
@@ -1,49 +0,0 @@
1
- // src/claims.ts
2
- function isAccessTokenClaims(payload) {
3
- return payload.token_type === "access" && typeof payload.sub === "string" && typeof payload.nickname === "string" && typeof payload.profile_image_url === "string" && (payload.role === "user" || payload.role === "admin") && Array.isArray(payload.approved_clients) && payload.approved_clients.every((clientId) => typeof clientId === "string");
4
- }
5
-
6
- // src/log.ts
7
- var PREFIX = "[lemon-auth]";
8
- function warnAuth(event, detail) {
9
- if (detail === void 0) {
10
- console.warn(`${PREFIX} ${event}`);
11
- return;
12
- }
13
- console.warn(`${PREFIX} ${event}`, JSON.stringify(detail));
14
- }
15
- function errorCode(error) {
16
- if (typeof error === "object" && error !== null) {
17
- const { code, cause } = error;
18
- if (typeof code === "string") return code;
19
- if (typeof cause === "object" && cause !== null) {
20
- const causeCode = cause.code;
21
- if (typeof causeCode === "string") return causeCode;
22
- }
23
- }
24
- if (error instanceof Error) return error.name;
25
- return "unknown";
26
- }
27
-
28
- // src/approval.ts
29
- var warnedMissingClientId = false;
30
- function isApprovedClient(approvedClients, clientId) {
31
- if (!clientId) {
32
- if (!warnedMissingClientId) {
33
- warnedMissingClientId = true;
34
- warnAuth(
35
- "clientId not provided \u2014 approval check is disabled (all users pass). \uC124\uC815 \uB204\uB77D\uC774 \uC544\uB2CC\uC9C0 \uD655\uC778\uD560 \uAC83."
36
- );
37
- }
38
- return true;
39
- }
40
- return approvedClients.includes(clientId) || approvedClients.includes("*");
41
- }
42
-
43
- export {
44
- isAccessTokenClaims,
45
- warnAuth,
46
- errorCode,
47
- isApprovedClient
48
- };
49
- //# sourceMappingURL=chunk-K4U4PHX6.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/claims.ts","../src/log.ts","../src/approval.ts"],"sourcesContent":["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 { 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":";AAGO,SAAS,oBACd,SAC8B;AAC9B,SACE,QAAQ,eAAe,YACvB,OAAO,QAAQ,QAAQ,YACvB,OAAO,QAAQ,aAAa,YAC5B,OAAO,QAAQ,sBAAsB,aACpC,QAAQ,SAAS,UAAU,QAAQ,SAAS,YAC7C,MAAM,QAAQ,QAAQ,gBAAgB,KACtC,QAAQ,iBAAiB,MAAM,CAAC,aAAa,OAAO,aAAa,QAAQ;AAE7E;;;ACEA,IAAM,SAAS;AAER,SAAS,SACd,OACA,QACM;AACN,MAAI,WAAW,QAAW;AACxB,YAAQ,KAAK,GAAG,MAAM,IAAI,KAAK,EAAE;AACjC;AAAA,EACF;AACA,UAAQ,KAAK,GAAG,MAAM,IAAI,KAAK,IAAI,KAAK,UAAU,MAAM,CAAC;AAC3D;AAYO,SAAS,UAAU,OAAwB;AAChD,MAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,UAAM,EAAE,MAAM,MAAM,IAAI;AACxB,QAAI,OAAO,SAAS,SAAU,QAAO;AACrC,QAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,YAAM,YAAa,MAA6B;AAChD,UAAI,OAAO,cAAc,SAAU,QAAO;AAAA,IAC5C;AAAA,EACF;AACA,MAAI,iBAAiB,MAAO,QAAO,MAAM;AACzC,SAAO;AACT;;;AChDA,IAAI,wBAAwB;AAErB,SAAS,iBACd,iBACA,UACS;AACT,MAAI,CAAC,UAAU;AAKb,QAAI,CAAC,uBAAuB;AAC1B,8BAAwB;AACxB;AAAA,QACE;AAAA,MAEF;AAAA,IACF;AACA,WAAO;AAAA,EACT;AACA,SAAO,gBAAgB,SAAS,QAAQ,KAAK,gBAAgB,SAAS,GAAG;AAC3E;","names":[]}