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