@lemondouble/lemon-auth 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -153
- package/dist/client/index.d.ts +16 -19
- package/dist/client/index.js +39 -99
- package/dist/client/index.js.map +1 -1
- package/dist/proxy/index.d.ts +1 -1
- package/dist/proxy/index.js +13 -4
- package/dist/proxy/index.js.map +1 -1
- package/dist/server/index.d.ts +1 -7
- package/dist/server/index.js +1 -34
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
>
|
|
243
|
-
>
|
|
244
|
-
>
|
|
245
|
-
> [0.
|
|
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/*"
|
|
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[]` | `[]` |
|
|
279
|
-
| `bypassPaths` | `string[]` | `[]` | proxy가
|
|
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 />` 를 마운트한 경로.
|
|
256
|
+
| `restorePath` | `string` | `"/auth/restore"` | `<SessionRestore />` 를 마운트한 경로. 프록시가 자동으로 통과시키므로 `publicPaths` 에 넣지 않아도 된다 |
|
|
283
257
|
|
|
284
258
|
#### 동작 방식
|
|
285
259
|
|
|
286
|
-
|
|
260
|
+
요청은 위에서부터 순서대로 판정됩니다.
|
|
287
261
|
|
|
288
|
-
1.
|
|
289
|
-
2.
|
|
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
|
-
|
|
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
|
-
|
|
304
|
+
갱신 주체가 `<SessionRestore />` 하나로 통일되고, 실패 처리가 서버로
|
|
305
|
+
넘어갔습니다. **auth-server v2026.08.03.2 이상이 먼저 배포되어 있어야 합니다**
|
|
306
|
+
— 죽은 토큰의 401 응답이 세션 쿠키를 지워주는 동작에 의존합니다.
|
|
353
307
|
|
|
354
|
-
**
|
|
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
|
-
|
|
364
|
-
| `unapprovedRedirectUrl` | `requireClient(clientId, { unapprovedRedirectTo })` |
|
|
365
|
-
| `onAuthSuccess` | DAL. **미들웨어에서 DB 를 조회하지 마십시오** (아래) |
|
|
318
|
+
**4. `<SessionRestore failedRedirectUrl>` prop 제거** — 실패해도 `next` 로
|
|
319
|
+
돌아갑니다. 죽은 토큰의 쿠키는 서버가 지워주므로, 이후 판단(로그인/익명)은
|
|
320
|
+
프록시와 DAL 이 합니다.
|
|
366
321
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
한 번만 돌게 하십시오.
|
|
322
|
+
**5. 공개 페이지의 "복구 중" 우회 코드 제거** — 공개 경로도 만료 세션이면
|
|
323
|
+
복구를 거친 뒤 처음부터 로그인 상태로 렌더되므로, refresh 쿠키 유무를 보고
|
|
324
|
+
스피너를 띄우던 코드는 필요 없습니다.
|
|
371
325
|
|
|
372
|
-
|
|
326
|
+
### API 경로
|
|
373
327
|
|
|
374
|
-
보호 경로 중 `apiPaths
|
|
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
|
-
|
|
503
|
-
|
|
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
|
-
-
|
|
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
|
-
클라이언트에서 토큰 갱신을 수행합니다. 보통
|
|
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
|
|
631
|
+
### 3. app/layout.tsx — AuthProvider
|
|
720
632
|
|
|
721
633
|
```tsx
|
|
722
634
|
import { getUser } from "@lemondouble/lemon-auth/server";
|
|
723
|
-
import { AuthProvider
|
|
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
|
package/dist/client/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
23
|
+
* 브라우저의 복구 경로는 한 번에 하나만 뜬다.
|
|
32
24
|
* 2. 브라우저 요청에는 진짜 UA 와 WAF 통과 쿠키가 실린다. 서버간 fetch 는
|
|
33
25
|
* UA 가 런타임 기본값이라 봇 방어에 걸릴 수 있다.
|
|
34
26
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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,
|
|
48
|
+
export { AuthProvider, LemonUser, SessionRestore, loginUrl, logout, profileUrl, refreshToken, useAuth };
|
package/dist/client/index.js
CHANGED
|
@@ -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/
|
|
27
|
+
// src/client/session-restore.tsx
|
|
31
28
|
import { useEffect, useState } from "react";
|
|
32
|
-
import {
|
|
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
|
|
70
|
-
if (isMockAuthEnabled()) return
|
|
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
|
-
|
|
63
|
+
if (res.ok) return "ok";
|
|
64
|
+
if (res.status === 401 || res.status === 403) return "unauthorized";
|
|
65
|
+
return "unavailable";
|
|
77
66
|
} catch {
|
|
78
|
-
return
|
|
67
|
+
return "unavailable";
|
|
79
68
|
}
|
|
80
69
|
}
|
|
81
|
-
function
|
|
82
|
-
|
|
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 {
|
|
162
|
-
|
|
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 [
|
|
171
|
-
|
|
98
|
+
const [unavailable, setUnavailable] = useState(false);
|
|
99
|
+
useEffect(() => {
|
|
172
100
|
let ignore = false;
|
|
173
101
|
const next = safeNextPath(searchParams.get("next"));
|
|
174
|
-
|
|
175
|
-
router.replace(next);
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
refreshToken().then((ok) => {
|
|
102
|
+
refreshSession().then((result) => {
|
|
179
103
|
if (ignore) return;
|
|
180
|
-
if (
|
|
181
|
-
|
|
104
|
+
if (result === "unavailable") {
|
|
105
|
+
setUnavailable(true);
|
|
182
106
|
return;
|
|
183
107
|
}
|
|
184
|
-
|
|
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
|
-
}, [
|
|
191
|
-
if (
|
|
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,
|
package/dist/client/index.js.map
CHANGED
|
@@ -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":"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","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":";;;;;;;;;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,uBAAuB;;;ACKzB,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;AAMA,eAAsB,SAA2B;AAC/C,MAAI,kBAAkB,EAAG,QAAO;AAEhC,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;AACF;AAWA,eAAsB,iBAAgD;AACpE,MAAI,kBAAkB,EAAG,QAAO;AAEhC,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,aAAa;AAAA,MACnC,QAAQ;AAAA,MACR,aAAa;AAAA,IACf,CAAC;AACD,QAAI,IAAI,GAAI,QAAO;AACnB,QAAI,IAAI,WAAW,OAAO,IAAI,WAAW,IAAK,QAAO;AACrD,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAsB,eAAiC;AACrD,SAAQ,MAAM,eAAe,MAAO;AACtC;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;;;ADzCM,SAUE,OAAAA,MAVF;AA5BC,SAAS,eAAe,EAAE,SAAS,GAAkC;AAC1E,QAAM,eAAe,gBAAgB;AACrC,QAAM,CAAC,aAAa,cAAc,IAAI,SAAS,KAAK;AAEpD,YAAU,MAAM;AACd,QAAI,SAAS;AACb,UAAM,OAAO,aAAa,aAAa,IAAI,MAAM,CAAC;AAElD,mBAAe,EAAE,KAAK,CAAC,WAAW;AAChC,UAAI,OAAQ;AAEZ,UAAI,WAAW,eAAe;AAG5B,uBAAe,IAAI;AACnB;AAAA,MACF;AAEA,aAAO,SAAS,QAAQ,IAAI;AAAA,IAC9B,CAAC;AAED,WAAO,MAAM;AACX,eAAS;AAAA,IACX;AAAA,EACF,GAAG,CAAC,YAAY,CAAC;AAEjB,MAAI,aAAa;AACf,WACE;AAAA,MAAC;AAAA;AAAA,QACC,OAAO;AAAA,UACL,WAAW;AAAA,UACX,SAAS;AAAA,UACT,eAAe;AAAA,UACf,YAAY;AAAA,UACZ,gBAAgB;AAAA,UAChB,KAAK;AAAA,QACP;AAAA,QAEA;AAAA,0BAAAA,KAAC,OAAE,sGAAkB;AAAA,UACrB,gBAAAA,KAAC,YAAO,MAAK,UAAS,SAAS,MAAM,OAAO,SAAS,OAAO,GAAG,uCAE/D;AAAA;AAAA;AAAA,IACF;AAAA,EAEJ;AAEA,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":["jsx"]}
|
package/dist/proxy/index.d.ts
CHANGED
|
@@ -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[];
|
package/dist/proxy/index.js
CHANGED
|
@@ -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 (
|
|
42
|
+
if (pathname === restorePath) {
|
|
43
43
|
return NextResponse.next();
|
|
44
44
|
}
|
|
45
|
-
if (isPublicPath(pathname,
|
|
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
|
|
56
|
-
if (
|
|
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
|
}
|
package/dist/proxy/index.js.map
CHANGED
|
@@ -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 * 프록시는
|
|
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":[]}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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
|
|
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 };
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
@@ -141,37 +140,6 @@ async function requireClient(clientId, optionsOrRedirectTo = "/") {
|
|
|
141
140
|
}
|
|
142
141
|
return session.user;
|
|
143
142
|
}
|
|
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
143
|
export {
|
|
176
144
|
ACCESS_TOKEN_COOKIE,
|
|
177
145
|
AUTH_SERVER_URL,
|
|
@@ -185,7 +153,6 @@ export {
|
|
|
185
153
|
REFRESH_URL,
|
|
186
154
|
getSession,
|
|
187
155
|
getUser,
|
|
188
|
-
refreshTokenFromCookie,
|
|
189
156
|
requireAuth,
|
|
190
157
|
requireClient,
|
|
191
158
|
verifyAccessToken,
|
package/dist/server/index.js.map
CHANGED
|
@@ -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 });\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":[]}
|