@lemondouble/lemon-auth 0.9.1 → 0.10.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 +34 -153
- package/dist/chunk-ICPDIWYK.js +79 -0
- package/dist/chunk-ICPDIWYK.js.map +1 -0
- package/dist/{chunk-OX5MDVSR.js → chunk-JIXVTKLG.js} +1 -4
- package/dist/{chunk-OX5MDVSR.js.map → chunk-JIXVTKLG.js.map} +1 -1
- package/dist/client/index.d.ts +3 -12
- package/dist/client/index.js +7 -71
- package/dist/client/index.js.map +1 -1
- package/dist/proxy/index.d.ts +1 -13
- package/dist/proxy/index.js +8 -18
- package/dist/proxy/index.js.map +1 -1
- package/dist/server/index.d.ts +8 -26
- package/dist/server/index.js +33 -74
- package/dist/server/index.js.map +1 -1
- package/dist/url-qhLR3uNu.d.ts +27 -0
- package/package.json +1 -1
- package/dist/types-B5uRY2Si.d.ts +0 -34
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ NEXT_PUBLIC_LEMON_AUTH_MOCK_USER='{"uid":"local-user","nickname":"Local User","p
|
|
|
22
22
|
|
|
23
23
|
Mock User가 활성화되면:
|
|
24
24
|
|
|
25
|
-
- `getUser()`, `getSession()`, `
|
|
25
|
+
- `getUser()`, `getSession()`, `requireClient()`는 mock user를 로그인 유저처럼 반환합니다.
|
|
26
26
|
- `createAuthProxy()`는 모든 요청을 통과시킵니다. 승인 체크는 DAL에서 수행됩니다.
|
|
27
27
|
- `loginUrl(redirectUrl)`은 `redirectUrl`을 그대로 반환합니다.
|
|
28
28
|
- `profileUrl(redirectUrl?)`은 `redirectUrl ?? "/"`를 반환합니다.
|
|
@@ -32,7 +32,7 @@ Mock User가 활성화되면:
|
|
|
32
32
|
|
|
33
33
|
| 경로 | 환경 | 용도 |
|
|
34
34
|
|------|------|------|
|
|
35
|
-
| `@lemondouble/lemon-auth/server` | Server Components, Route Handlers, Server Actions |
|
|
35
|
+
| `@lemondouble/lemon-auth/server` | Server Components, Route Handlers, Server Actions | 유저 조회, 인증 체크, URL 헬퍼 |
|
|
36
36
|
| `@lemondouble/lemon-auth/client` | Client Components | AuthProvider, useAuth 훅, URL 헬퍼 |
|
|
37
37
|
| `@lemondouble/lemon-auth/proxy` | proxy.ts | 쿠키 기반 optimistic check |
|
|
38
38
|
|
|
@@ -42,55 +42,9 @@ Mock User가 활성화되면:
|
|
|
42
42
|
|
|
43
43
|
Server Components, Route Handlers, Server Actions에서 사용합니다.
|
|
44
44
|
|
|
45
|
-
### `verifyAccessToken()`
|
|
46
|
-
|
|
47
|
-
쿠키에서 `lemon_access_token`을 읽어 JWKS(ES256)로 검증합니다.
|
|
48
|
-
|
|
49
|
-
```ts
|
|
50
|
-
import { verifyAccessToken } from "@lemondouble/lemon-auth/server";
|
|
51
|
-
|
|
52
|
-
const claims = await verifyAccessToken();
|
|
53
|
-
// → AccessTokenClaims | null
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
반환되는 `AccessTokenClaims`:
|
|
57
|
-
|
|
58
|
-
```ts
|
|
59
|
-
interface AccessTokenClaims {
|
|
60
|
-
token_type: "access"; // access token 여부
|
|
61
|
-
sub: string; // 사용자 UUID
|
|
62
|
-
nickname: string; // 닉네임
|
|
63
|
-
profile_image_url: string; // 프로필 이미지 URL
|
|
64
|
-
role: "user" | "admin"; // 역할
|
|
65
|
-
approved_clients: string[]; // 승인된 클라이언트 ID 목록
|
|
66
|
-
iss: string; // issuer (https://auth.lemondouble.com)
|
|
67
|
-
exp: number; // 만료 시간
|
|
68
|
-
iat: number; // 발급 시간
|
|
69
|
-
}
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
### `verifyAccessTokenString(token)`
|
|
73
|
-
|
|
74
|
-
토큰 문자열을 직접 전달하여 검증합니다. 쿠키 대신 `Authorization` 헤더 등에서 토큰을 꺼낼 때 사용합니다.
|
|
75
|
-
|
|
76
|
-
```ts
|
|
77
|
-
import { verifyAccessTokenString } from "@lemondouble/lemon-auth/server";
|
|
78
|
-
|
|
79
|
-
// Route Handler에서 Authorization 헤더 사용 예시
|
|
80
|
-
export async function GET(request: Request) {
|
|
81
|
-
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
|
|
82
|
-
if (!token) return new Response(null, { status: 401 });
|
|
83
|
-
|
|
84
|
-
const claims = await verifyAccessTokenString(token);
|
|
85
|
-
if (!claims) return new Response(null, { status: 401 });
|
|
86
|
-
|
|
87
|
-
return Response.json({ userId: claims.sub });
|
|
88
|
-
}
|
|
89
|
-
```
|
|
90
|
-
|
|
91
45
|
### `getUser()`
|
|
92
46
|
|
|
93
|
-
`
|
|
47
|
+
쿠키의 `lemon_access_token`을 JWKS(ES256)로 검증하고 `LemonUser` 객체로 변환합니다.
|
|
94
48
|
React `cache()`로 감싸져 있어서 **같은 요청 내에서 여러 번 호출해도 JWT 검증은 1회만** 실행됩니다.
|
|
95
49
|
|
|
96
50
|
```ts
|
|
@@ -117,9 +71,9 @@ interface LemonUser {
|
|
|
117
71
|
}
|
|
118
72
|
```
|
|
119
73
|
|
|
120
|
-
### `getSession(
|
|
74
|
+
### `getSession({ clientId })`
|
|
121
75
|
|
|
122
|
-
로그인 상태와 클라이언트 승인 상태를 함께 반환합니다.
|
|
76
|
+
로그인 상태와 클라이언트 승인 상태를 함께 반환합니다. `clientId`는 필수입니다. 빈 값(환경변수 누락 등)이면 모든 유저가 `unapproved`가 됩니다. 승인 없이 로그인 여부만 필요하면 `getUser()`를 씁니다.
|
|
123
77
|
|
|
124
78
|
```ts
|
|
125
79
|
import { getSession } from "@lemondouble/lemon-auth/server";
|
|
@@ -145,28 +99,9 @@ type LemonSession =
|
|
|
145
99
|
| { type: "authenticated"; user: LemonUser };
|
|
146
100
|
```
|
|
147
101
|
|
|
148
|
-
### `requireAuth(redirectTo?)`
|
|
149
|
-
|
|
150
|
-
인증된 유저를 반환합니다. 미인증이면 `redirectTo`로 redirect합니다.
|
|
151
|
-
|
|
152
|
-
```ts
|
|
153
|
-
import { requireAuth } from "@lemondouble/lemon-auth/server";
|
|
154
|
-
|
|
155
|
-
export default async function ProtectedPage() {
|
|
156
|
-
const user = await requireAuth(); // 미인증 시 "/" 로 redirect
|
|
157
|
-
// const user = await requireAuth("/login"); // 미인증 시 "/login" 으로 redirect
|
|
158
|
-
|
|
159
|
-
return <p>{user.nickname}님의 대시보드</p>;
|
|
160
|
-
}
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
| 파라미터 | 타입 | 기본값 | 설명 |
|
|
164
|
-
|---------|------|--------|------|
|
|
165
|
-
| `redirectTo` | `string` | `"/"` | 미인증 시 redirect 경로 |
|
|
166
|
-
|
|
167
102
|
### `requireClient(clientId, options?)`
|
|
168
103
|
|
|
169
|
-
|
|
104
|
+
로그인 + `approved_clients` 체크. 관리자가 승인한 사용자만 접근할 수 있는 서비스에서 사용합니다.
|
|
170
105
|
|
|
171
106
|
```ts
|
|
172
107
|
import { requireClient } from "@lemondouble/lemon-auth/server";
|
|
@@ -186,25 +121,14 @@ export default async function Page() {
|
|
|
186
121
|
|---------|------|--------|------|
|
|
187
122
|
| `clientId` | `string` | (필수) | 체크할 클라이언트 UUID |
|
|
188
123
|
| `options.loginRedirectTo` | `string` | `"/"` | 미인증 시 redirect 경로 |
|
|
189
|
-
| `options.unapprovedRedirectTo` | `string` | `
|
|
124
|
+
| `options.unapprovedRedirectTo` | `string` | auth-server `/error?code=FORBIDDEN` | 로그인은 됐지만 클라이언트 미승인 시 redirect 경로 |
|
|
190
125
|
|
|
191
|
-
###
|
|
126
|
+
### `loginUrl()` / `profileUrl()`
|
|
192
127
|
|
|
193
|
-
|
|
128
|
+
Client 엔트리포인트의 같은 함수를 Server Component에서도 쓸 수 있게 re-export합니다. 사용법은 [Client](#client--lemondoublelemon-authclient) 참고.
|
|
194
129
|
|
|
195
130
|
```ts
|
|
196
|
-
import {
|
|
197
|
-
AUTH_SERVER_URL, // "https://auth.lemondouble.com"
|
|
198
|
-
JWKS_URL, // "https://auth.lemondouble.com/.well-known/jwks.json"
|
|
199
|
-
LOGIN_URL, // "https://auth.lemondouble.com/api/oauth2/google/login"
|
|
200
|
-
REFRESH_URL, // "https://auth.lemondouble.com/api/token/refresh"
|
|
201
|
-
LOGOUT_URL, // "https://auth.lemondouble.com/api/token/logout"
|
|
202
|
-
PROFILE_URL, // "https://auth.lemondouble.com/api/user/me"
|
|
203
|
-
PROFILE_PAGE_URL, // "https://auth.lemondouble.com/profile"
|
|
204
|
-
ACCESS_TOKEN_COOKIE, // "lemon_access_token"
|
|
205
|
-
REFRESH_TOKEN_COOKIE, // "lemon_refresh_token"
|
|
206
|
-
DEVICE_ID_COOKIE, // "device_id"
|
|
207
|
-
} from "@lemondouble/lemon-auth/server";
|
|
131
|
+
import { loginUrl, profileUrl } from "@lemondouble/lemon-auth/server";
|
|
208
132
|
```
|
|
209
133
|
|
|
210
134
|
---
|
|
@@ -234,7 +158,6 @@ import { createAuthProxy } from "@lemondouble/lemon-auth/proxy";
|
|
|
234
158
|
|
|
235
159
|
export default createAuthProxy({
|
|
236
160
|
publicPaths: ["/", "/about", "/api/public/*"],
|
|
237
|
-
bypassPaths: ["/workbox-*"],
|
|
238
161
|
loginRedirectUrl: (request) => request.url,
|
|
239
162
|
});
|
|
240
163
|
|
|
@@ -250,23 +173,23 @@ export const config = {
|
|
|
250
173
|
| 옵션 | 타입 | 기본값 | 설명 |
|
|
251
174
|
|------|------|--------|------|
|
|
252
175
|
| `publicPaths` | `string[]` | `[]` | 로그인 없이 열어둘 경로. 익명이면 통과, 만료 세션이면 복구를 거친다. `*`로 끝나면 prefix 매칭 (예: `"/api/public/*"`) |
|
|
253
|
-
| `bypassPaths` | `string[]` | `[]` | proxy가 아무 판단 없이 통과시킬 추가 경로. 기본 PWA 경로(`/sw.js`, `/manifest.webmanifest` 등)는 항상 bypass |
|
|
254
176
|
| `apiPaths` | `string[]` | `["/api/*"]` | 보호 API 경로. 인증 실패 시 redirect 대신 401 JSON을 반환한다. disable하려면 `[]` |
|
|
255
177
|
| `loginRedirectUrl` | `string \| (request: NextRequest) => string` | `undefined` | 미인증 시 Google 로그인 후 돌아올 URL. deep link 보존이 필요하면 `(request) => request.url` 사용. 미설정 시 `"/"` 로 redirect |
|
|
256
|
-
| `restorePath` | `string` | `"/auth/restore"` | `<SessionRestore />` 를 마운트한 경로. 프록시가 자동으로 통과시키므로 `publicPaths` 에 넣지 않아도 된다 |
|
|
257
178
|
|
|
258
179
|
#### 동작 방식
|
|
259
180
|
|
|
181
|
+
`<SessionRestore />`는 반드시 `/auth/restore`에 마운트합니다.
|
|
182
|
+
|
|
260
183
|
요청은 위에서부터 순서대로 판정됩니다.
|
|
261
184
|
|
|
262
|
-
1. `
|
|
263
|
-
2.
|
|
185
|
+
1. `/auth/restore` → 통과 (여기서 복구 경로로 보내면 무한 루프)
|
|
186
|
+
2. PWA·정적 리소스 경로(`/sw.js`, `/manifest.webmanifest`, `/icons/*` 등) → 통과
|
|
264
187
|
3. `lemon_access_token` 이 있고 만료까지 60초 이상 → 통과
|
|
265
188
|
4. — 여기부터는 만료된 요청 —
|
|
266
189
|
`apiPaths` 매칭 → 공개 경로면 통과, 아니면 `401 { code: "UNAUTHORIZED" }`
|
|
267
190
|
5. 비-GET 요청 (서버 액션 등) → 통과. 리다이렉트하면 액션 프로토콜이
|
|
268
191
|
깨지므로, DAL 이 거부하게 둡니다
|
|
269
|
-
6. `lemon_refresh_token` 있음 →
|
|
192
|
+
6. `lemon_refresh_token` 있음 → `/auth/restore?next=<원래 경로>` 로 redirect.
|
|
270
193
|
**공개 경로도 포함입니다**
|
|
271
194
|
7. 공개 경로 → 통과 (익명 방문자)
|
|
272
195
|
8. 나머지 → `loginRedirectUrl` 을 담아 로그인 페이지로 redirect
|
|
@@ -277,7 +200,7 @@ export const config = {
|
|
|
277
200
|
|
|
278
201
|
**프리페치 요청**: 다른 요청과 똑같이 처리합니다. 통과시키면 만료된 토큰으로
|
|
279
202
|
페이지가 렌더되고 거기서 나온 redirect 가 라우터 캐시에 굳어, 어느 링크를 눌러도
|
|
280
|
-
같은 곳으로 튀게 됩니다. 반면 프록시가 돌려주는
|
|
203
|
+
같은 곳으로 튀게 됩니다. 반면 프록시가 돌려주는 복구 경로 리다이렉트는 굳어도
|
|
281
204
|
해가 없습니다 — 복구 경로는 막다른 길이 아니라 세션을 되살리고 `next` 로
|
|
282
205
|
되돌려 보내므로 목적지도 보존됩니다.
|
|
283
206
|
|
|
@@ -299,6 +222,16 @@ createAuthProxy({
|
|
|
299
222
|
});
|
|
300
223
|
```
|
|
301
224
|
|
|
225
|
+
### 0.9.x → 0.10.0 마이그레이션
|
|
226
|
+
|
|
227
|
+
쓰이지 않던 API를 정리했습니다.
|
|
228
|
+
|
|
229
|
+
- 제거: `requireAuth`, `verifyAccessToken`, `verifyAccessTokenString`, `/server`의 URL·쿠키 상수, `AccessTokenClaims`·`UserProfile` 타입
|
|
230
|
+
- 제거: proxy의 `bypassPaths`·`restorePath` 옵션, `DEFAULT_AUTH_BYPASS_PATHS`·`DEFAULT_RESTORE_PATH`·`PROXY_AUTH_ERROR`
|
|
231
|
+
- `getSession()`의 `clientId`가 필수가 됐습니다. 빈 값이면 승인 검사를 건너뛰던(fail-open) 동작이 `unapproved`로 바뀝니다.
|
|
232
|
+
- `requireClient()`의 두 번째 인자는 옵션 객체만 받습니다.
|
|
233
|
+
- `LOGIN_URL`·`PROFILE_PAGE_URL`로 URL을 직접 만들던 곳은 `/server`에서 `loginUrl()`·`profileUrl()`을 import해 씁니다.
|
|
234
|
+
|
|
302
235
|
### 0.8.x → 0.9.0 마이그레이션
|
|
303
236
|
|
|
304
237
|
갱신 주체가 `<SessionRestore />` 하나로 통일되고, 실패 처리가 서버로
|
|
@@ -309,7 +242,7 @@ createAuthProxy({
|
|
|
309
242
|
레이아웃에서 마운트와 import 를 지웁니다. 공개 경로의 만료 세션은 이제
|
|
310
243
|
프록시가 복구 경로로 보내 처리합니다.
|
|
311
244
|
|
|
312
|
-
**2. `publicPaths` 에서 복구 경로 제거** — `
|
|
245
|
+
**2. `publicPaths` 에서 복구 경로 제거** — `/auth/restore` 는 프록시가 자동으로
|
|
313
246
|
통과시킵니다. 남겨둬도 해는 없지만 필요 없습니다.
|
|
314
247
|
|
|
315
248
|
**3. `refreshTokenFromCookie` 제거** — 서버사이드 갱신 진입점이 사라졌습니다.
|
|
@@ -330,25 +263,6 @@ createAuthProxy({
|
|
|
330
263
|
| 상황 | 응답 |
|
|
331
264
|
|------|------|
|
|
332
265
|
| 미인증 (access/refresh 모두 실패) | `401 { "code": "UNAUTHORIZED" }` |
|
|
333
|
-
| 미승인 (`clientId` 미포함) | `403 { "code": "FORBIDDEN" }` |
|
|
334
|
-
|
|
335
|
-
`PROXY_AUTH_ERROR` 상수와 `ProxyAuthErrorCode` 타입을 export합니다.
|
|
336
|
-
|
|
337
|
-
```ts
|
|
338
|
-
import { PROXY_AUTH_ERROR } from "@lemondouble/lemon-auth/proxy";
|
|
339
|
-
import type { ProxyAuthErrorCode } from "@lemondouble/lemon-auth/proxy";
|
|
340
|
-
|
|
341
|
-
const res = await fetch("/api/users");
|
|
342
|
-
if (!res.ok) {
|
|
343
|
-
const data = (await res.json()) as { code: ProxyAuthErrorCode };
|
|
344
|
-
if (data.code === PROXY_AUTH_ERROR.UNAUTHORIZED) {
|
|
345
|
-
// 재로그인 유도
|
|
346
|
-
}
|
|
347
|
-
if (data.code === PROXY_AUTH_ERROR.FORBIDDEN) {
|
|
348
|
-
// "권한 없음" 표시
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
```
|
|
352
266
|
|
|
353
267
|
`/api/*` 외의 경로(예: tRPC, GraphQL)도 JSON 응답을 받게 하려면 `apiPaths`에 추가합니다.
|
|
354
268
|
|
|
@@ -521,38 +435,9 @@ const ok = await refreshToken();
|
|
|
521
435
|
|
|
522
436
|
## 타입
|
|
523
437
|
|
|
524
|
-
세 엔트리포인트 모두에서 타입을 import할 수 있습니다.
|
|
525
|
-
|
|
526
438
|
```ts
|
|
527
|
-
import type {
|
|
439
|
+
import type { LemonSession, LemonUser } from "@lemondouble/lemon-auth/server";
|
|
528
440
|
import type { LemonUser } from "@lemondouble/lemon-auth/client";
|
|
529
|
-
import type { LemonUser, AccessTokenClaims } from "@lemondouble/lemon-auth/proxy";
|
|
530
|
-
```
|
|
531
|
-
|
|
532
|
-
```ts
|
|
533
|
-
interface LemonUser {
|
|
534
|
-
uid: string;
|
|
535
|
-
nickname: string;
|
|
536
|
-
profileImageUrl: string;
|
|
537
|
-
role: "user" | "admin";
|
|
538
|
-
approvedClients: string[];
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
interface AccessTokenClaims extends JWTPayload {
|
|
542
|
-
token_type: "access";
|
|
543
|
-
sub: string;
|
|
544
|
-
nickname: string;
|
|
545
|
-
profile_image_url: string;
|
|
546
|
-
role: "user" | "admin";
|
|
547
|
-
approved_clients: string[];
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
interface UserProfile {
|
|
551
|
-
uid: string;
|
|
552
|
-
nickname: string;
|
|
553
|
-
profile_image_url: string;
|
|
554
|
-
role: "user" | "admin";
|
|
555
|
-
}
|
|
556
441
|
```
|
|
557
442
|
|
|
558
443
|
---
|
|
@@ -564,10 +449,7 @@ interface UserProfile {
|
|
|
564
449
|
### 1. proxy.ts — optimistic check
|
|
565
450
|
|
|
566
451
|
```ts
|
|
567
|
-
import {
|
|
568
|
-
createAuthProxy,
|
|
569
|
-
DEFAULT_API_PATHS,
|
|
570
|
-
} from "@lemondouble/lemon-auth/proxy";
|
|
452
|
+
import { createAuthProxy } from "@lemondouble/lemon-auth/proxy";
|
|
571
453
|
|
|
572
454
|
export default createAuthProxy({
|
|
573
455
|
publicPaths: [
|
|
@@ -576,8 +458,6 @@ export default createAuthProxy({
|
|
|
576
458
|
"/pending-approval",
|
|
577
459
|
"/api/public/*",
|
|
578
460
|
],
|
|
579
|
-
bypassPaths: ["/workbox-*"],
|
|
580
|
-
apiPaths: DEFAULT_API_PATHS,
|
|
581
461
|
loginRedirectUrl: (request) => request.url,
|
|
582
462
|
});
|
|
583
463
|
|
|
@@ -589,8 +469,7 @@ export const config = {
|
|
|
589
469
|
```
|
|
590
470
|
|
|
591
471
|
`loginRedirectUrl: (request) => request.url`은 로그인 후 사용자가 원래 요청한 path/query로 돌아오게 합니다.
|
|
592
|
-
|
|
593
|
-
기본 PWA 파일(`/sw.js`, `/manifest.webmanifest` 등)은 항상 bypass되며, 앱에서 추가로 생성하는 workbox 파일은 `bypassPaths`에 넣을 수 있습니다.
|
|
472
|
+
`/api/*` 보호 API는 인증 실패 시 redirect 대신 `401` JSON 응답을 반환합니다.
|
|
594
473
|
복구 경로(`/auth/restore`)는 프록시가 자동으로 통과시키므로 `publicPaths` 에 넣지 않습니다.
|
|
595
474
|
|
|
596
475
|
사용자 DB 동기화 같은 작업은 **프록시가 아니라 DAL 에서** 하십시오. 프록시는
|
|
@@ -677,10 +556,12 @@ export function LoginButton() {
|
|
|
677
556
|
### 5. app/dashboard/page.tsx — 보호 페이지
|
|
678
557
|
|
|
679
558
|
```tsx
|
|
680
|
-
import {
|
|
559
|
+
import { requireClient } from "@lemondouble/lemon-auth/server";
|
|
681
560
|
|
|
682
561
|
export default async function Dashboard() {
|
|
683
|
-
const user = await
|
|
562
|
+
const user = await requireClient(process.env.CLIENT_ID!, {
|
|
563
|
+
unapprovedRedirectTo: "/pending-approval",
|
|
564
|
+
});
|
|
684
565
|
return <h1>{user.nickname}님의 대시보드</h1>;
|
|
685
566
|
}
|
|
686
567
|
```
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LOGIN_URL,
|
|
3
|
+
LOGOUT_URL,
|
|
4
|
+
PROFILE_PAGE_URL,
|
|
5
|
+
REFRESH_URL,
|
|
6
|
+
isMockAuthEnabled
|
|
7
|
+
} from "./chunk-JIXVTKLG.js";
|
|
8
|
+
|
|
9
|
+
// src/client/url.ts
|
|
10
|
+
function loginUrl(redirectUrl) {
|
|
11
|
+
if (isMockAuthEnabled()) return redirectUrl;
|
|
12
|
+
assertValidLoginRedirectUrl(redirectUrl);
|
|
13
|
+
return `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;
|
|
14
|
+
}
|
|
15
|
+
function profileUrl(redirectUrl) {
|
|
16
|
+
if (isMockAuthEnabled()) return redirectUrl ?? "/";
|
|
17
|
+
if (redirectUrl) {
|
|
18
|
+
return `${PROFILE_PAGE_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;
|
|
19
|
+
}
|
|
20
|
+
return PROFILE_PAGE_URL;
|
|
21
|
+
}
|
|
22
|
+
async function logout() {
|
|
23
|
+
if (isMockAuthEnabled()) return true;
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(LOGOUT_URL, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
credentials: "include"
|
|
28
|
+
});
|
|
29
|
+
return res.ok;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function refreshSession() {
|
|
35
|
+
if (isMockAuthEnabled()) return "ok";
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(REFRESH_URL, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
credentials: "include"
|
|
40
|
+
});
|
|
41
|
+
if (res.ok) return "ok";
|
|
42
|
+
if (res.status === 401 || res.status === 403) return "unauthorized";
|
|
43
|
+
return "unavailable";
|
|
44
|
+
} catch {
|
|
45
|
+
return "unavailable";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function refreshToken() {
|
|
49
|
+
return await refreshSession() === "ok";
|
|
50
|
+
}
|
|
51
|
+
function assertValidLoginRedirectUrl(redirectUrl) {
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(redirectUrl);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error("loginUrl redirectUrl must be a valid absolute URL");
|
|
57
|
+
}
|
|
58
|
+
if (url.protocol !== "https:") {
|
|
59
|
+
throw new Error("loginUrl redirectUrl must use https");
|
|
60
|
+
}
|
|
61
|
+
if (!isAllowedLoginRedirectHost(url.hostname)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"loginUrl redirectUrl host must be lemondouble.com or a subdomain of lemondouble.com"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function isAllowedLoginRedirectHost(hostname) {
|
|
68
|
+
const normalized = hostname.toLowerCase();
|
|
69
|
+
return normalized === "lemondouble.com" || normalized.endsWith(".lemondouble.com");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export {
|
|
73
|
+
loginUrl,
|
|
74
|
+
profileUrl,
|
|
75
|
+
logout,
|
|
76
|
+
refreshSession,
|
|
77
|
+
refreshToken
|
|
78
|
+
};
|
|
79
|
+
//# sourceMappingURL=chunk-ICPDIWYK.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/client/url.ts"],"sourcesContent":["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":";;;;;;;;;AAQO,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;","names":[]}
|
|
@@ -8,7 +8,6 @@ var PROFILE_URL = `${AUTH_SERVER_URL}/api/user/me`;
|
|
|
8
8
|
var PROFILE_PAGE_URL = `${AUTH_SERVER_URL}/profile`;
|
|
9
9
|
var ACCESS_TOKEN_COOKIE = "lemon_access_token";
|
|
10
10
|
var REFRESH_TOKEN_COOKIE = "lemon_refresh_token";
|
|
11
|
-
var DEVICE_ID_COOKIE = "device_id";
|
|
12
11
|
|
|
13
12
|
// src/mock.ts
|
|
14
13
|
var LEMON_AUTH_MOCK_USER_ENV = "NEXT_PUBLIC_LEMON_AUTH_MOCK_USER";
|
|
@@ -109,12 +108,10 @@ export {
|
|
|
109
108
|
LOGIN_URL,
|
|
110
109
|
REFRESH_URL,
|
|
111
110
|
LOGOUT_URL,
|
|
112
|
-
PROFILE_URL,
|
|
113
111
|
PROFILE_PAGE_URL,
|
|
114
112
|
ACCESS_TOKEN_COOKIE,
|
|
115
113
|
REFRESH_TOKEN_COOKIE,
|
|
116
|
-
DEVICE_ID_COOKIE,
|
|
117
114
|
isMockAuthEnabled,
|
|
118
115
|
getMockAccessTokenClaims
|
|
119
116
|
};
|
|
120
|
-
//# sourceMappingURL=chunk-
|
|
117
|
+
//# sourceMappingURL=chunk-JIXVTKLG.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/constants.ts","../src/mock.ts"],"sourcesContent":["export const AUTH_SERVER_URL = \"https://auth.lemondouble.com\";\nexport const JWKS_URL = `${AUTH_SERVER_URL}/.well-known/jwks.json`;\nexport const LOGIN_URL = `${AUTH_SERVER_URL}/api/oauth2/google/login`;\nexport const REFRESH_URL = `${AUTH_SERVER_URL}/api/token/refresh`;\nexport const LOGOUT_URL = `${AUTH_SERVER_URL}/api/token/logout`;\nexport const PROFILE_URL = `${AUTH_SERVER_URL}/api/user/me`;\nexport const PROFILE_PAGE_URL = `${AUTH_SERVER_URL}/profile`;\n\nexport const ACCESS_TOKEN_COOKIE = \"lemon_access_token\";\nexport const REFRESH_TOKEN_COOKIE = \"lemon_refresh_token\";\nexport const DEVICE_ID_COOKIE = \"device_id\";\n","import type { AccessTokenClaims, LemonUser } from \"./types.js\";\n\nexport const LEMON_AUTH_MOCK_USER_ENV =\n \"NEXT_PUBLIC_LEMON_AUTH_MOCK_USER\";\n\nexport function isMockAuthEnabled(): boolean {\n return !isProduction() && Boolean(readMockUserEnv());\n}\n\nexport function getMockUser(): LemonUser | null {\n const raw = readMockUserEnv();\n if (isProduction() || !raw) return null;\n return parseMockUser(raw);\n}\n\nexport function getMockAccessTokenClaims(): AccessTokenClaims | null {\n const user = getMockUser();\n if (!user) return null;\n\n const now = Math.floor(Date.now() / 1000);\n return {\n token_type: \"access\",\n sub: user.uid,\n nickname: user.nickname,\n profile_image_url: user.profileImageUrl,\n role: user.role,\n approved_clients: user.approvedClients,\n iss: \"lemon-auth-mock\",\n iat: now,\n exp: now + 60 * 60 * 24 * 365,\n };\n}\n\nfunction readMockUserEnv(): string | undefined {\n try {\n return process.env.NEXT_PUBLIC_LEMON_AUTH_MOCK_USER;\n } catch {\n return undefined;\n }\n}\n\nfunction isProduction(): boolean {\n try {\n return process.env.NODE_ENV === \"production\";\n } catch {\n return false;\n }\n}\n\nfunction parseMockUser(raw: string): LemonUser {\n let data: unknown;\n try {\n data = JSON.parse(raw);\n } catch {\n throw new Error(\n `${LEMON_AUTH_MOCK_USER_ENV} must be a valid JSON object`\n );\n }\n\n if (!isRecord(data)) {\n throw new Error(`${LEMON_AUTH_MOCK_USER_ENV} must be a JSON object`);\n }\n\n return {\n uid: readOptionalString(data.uid, \"local-user\", \"uid\"),\n nickname: readOptionalString(data.nickname, \"Local User\", \"nickname\"),\n profileImageUrl: readOptionalString(\n data.profileImageUrl ?? data.profile_image_url,\n \"\",\n \"profileImageUrl\"\n ),\n role: readOptionalRole(data.role, \"admin\"),\n approvedClients: readOptionalStringArray(\n data.approvedClients ?? data.approved_clients,\n [\"*\"],\n \"approvedClients\"\n ),\n };\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction readOptionalString(\n value: unknown,\n fallback: string,\n field: string\n): string {\n if (value === undefined || value === null) return fallback;\n if (typeof value === \"string\") return value;\n throw new Error(`${LEMON_AUTH_MOCK_USER_ENV}.${field} must be a string`);\n}\n\nfunction readOptionalRole(\n value: unknown,\n fallback: LemonUser[\"role\"]\n): LemonUser[\"role\"] {\n if (value === undefined || value === null) return fallback;\n if (value === \"user\" || value === \"admin\") return value;\n throw new Error(\n `${LEMON_AUTH_MOCK_USER_ENV}.role must be \"user\" or \"admin\"`\n );\n}\n\nfunction readOptionalStringArray(\n value: unknown,\n fallback: string[],\n field: string\n): string[] {\n if (value === undefined || value === null) return fallback;\n if (\n Array.isArray(value) &&\n value.every((item) => typeof item === \"string\")\n ) {\n return value;\n }\n throw new Error(\n `${LEMON_AUTH_MOCK_USER_ENV}.${field} must be a string array`\n );\n}\n"],"mappings":";AAAO,IAAM,kBAAkB;AACxB,IAAM,WAAW,GAAG,eAAe;AACnC,IAAM,YAAY,GAAG,eAAe;AACpC,IAAM,cAAc,GAAG,eAAe;AACtC,IAAM,aAAa,GAAG,eAAe;AACrC,IAAM,cAAc,GAAG,eAAe;AACtC,IAAM,mBAAmB,GAAG,eAAe;AAE3C,IAAM,sBAAsB;AAC5B,IAAM,uBAAuB
|
|
1
|
+
{"version":3,"sources":["../src/constants.ts","../src/mock.ts"],"sourcesContent":["export const AUTH_SERVER_URL = \"https://auth.lemondouble.com\";\nexport const JWKS_URL = `${AUTH_SERVER_URL}/.well-known/jwks.json`;\nexport const LOGIN_URL = `${AUTH_SERVER_URL}/api/oauth2/google/login`;\nexport const REFRESH_URL = `${AUTH_SERVER_URL}/api/token/refresh`;\nexport const LOGOUT_URL = `${AUTH_SERVER_URL}/api/token/logout`;\nexport const PROFILE_URL = `${AUTH_SERVER_URL}/api/user/me`;\nexport const PROFILE_PAGE_URL = `${AUTH_SERVER_URL}/profile`;\n\nexport const ACCESS_TOKEN_COOKIE = \"lemon_access_token\";\nexport const REFRESH_TOKEN_COOKIE = \"lemon_refresh_token\";\nexport const DEVICE_ID_COOKIE = \"device_id\";\n","import type { AccessTokenClaims, LemonUser } from \"./types.js\";\n\nexport const LEMON_AUTH_MOCK_USER_ENV =\n \"NEXT_PUBLIC_LEMON_AUTH_MOCK_USER\";\n\nexport function isMockAuthEnabled(): boolean {\n return !isProduction() && Boolean(readMockUserEnv());\n}\n\nexport function getMockUser(): LemonUser | null {\n const raw = readMockUserEnv();\n if (isProduction() || !raw) return null;\n return parseMockUser(raw);\n}\n\nexport function getMockAccessTokenClaims(): AccessTokenClaims | null {\n const user = getMockUser();\n if (!user) return null;\n\n const now = Math.floor(Date.now() / 1000);\n return {\n token_type: \"access\",\n sub: user.uid,\n nickname: user.nickname,\n profile_image_url: user.profileImageUrl,\n role: user.role,\n approved_clients: user.approvedClients,\n iss: \"lemon-auth-mock\",\n iat: now,\n exp: now + 60 * 60 * 24 * 365,\n };\n}\n\nfunction readMockUserEnv(): string | undefined {\n try {\n return process.env.NEXT_PUBLIC_LEMON_AUTH_MOCK_USER;\n } catch {\n return undefined;\n }\n}\n\nfunction isProduction(): boolean {\n try {\n return process.env.NODE_ENV === \"production\";\n } catch {\n return false;\n }\n}\n\nfunction parseMockUser(raw: string): LemonUser {\n let data: unknown;\n try {\n data = JSON.parse(raw);\n } catch {\n throw new Error(\n `${LEMON_AUTH_MOCK_USER_ENV} must be a valid JSON object`\n );\n }\n\n if (!isRecord(data)) {\n throw new Error(`${LEMON_AUTH_MOCK_USER_ENV} must be a JSON object`);\n }\n\n return {\n uid: readOptionalString(data.uid, \"local-user\", \"uid\"),\n nickname: readOptionalString(data.nickname, \"Local User\", \"nickname\"),\n profileImageUrl: readOptionalString(\n data.profileImageUrl ?? data.profile_image_url,\n \"\",\n \"profileImageUrl\"\n ),\n role: readOptionalRole(data.role, \"admin\"),\n approvedClients: readOptionalStringArray(\n data.approvedClients ?? data.approved_clients,\n [\"*\"],\n \"approvedClients\"\n ),\n };\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction readOptionalString(\n value: unknown,\n fallback: string,\n field: string\n): string {\n if (value === undefined || value === null) return fallback;\n if (typeof value === \"string\") return value;\n throw new Error(`${LEMON_AUTH_MOCK_USER_ENV}.${field} must be a string`);\n}\n\nfunction readOptionalRole(\n value: unknown,\n fallback: LemonUser[\"role\"]\n): LemonUser[\"role\"] {\n if (value === undefined || value === null) return fallback;\n if (value === \"user\" || value === \"admin\") return value;\n throw new Error(\n `${LEMON_AUTH_MOCK_USER_ENV}.role must be \"user\" or \"admin\"`\n );\n}\n\nfunction readOptionalStringArray(\n value: unknown,\n fallback: string[],\n field: string\n): string[] {\n if (value === undefined || value === null) return fallback;\n if (\n Array.isArray(value) &&\n value.every((item) => typeof item === \"string\")\n ) {\n return value;\n }\n throw new Error(\n `${LEMON_AUTH_MOCK_USER_ENV}.${field} must be a string array`\n );\n}\n"],"mappings":";AAAO,IAAM,kBAAkB;AACxB,IAAM,WAAW,GAAG,eAAe;AACnC,IAAM,YAAY,GAAG,eAAe;AACpC,IAAM,cAAc,GAAG,eAAe;AACtC,IAAM,aAAa,GAAG,eAAe;AACrC,IAAM,cAAc,GAAG,eAAe;AACtC,IAAM,mBAAmB,GAAG,eAAe;AAE3C,IAAM,sBAAsB;AAC5B,IAAM,uBAAuB;;;ACP7B,IAAM,2BACX;AAEK,SAAS,oBAA6B;AAC3C,SAAO,CAAC,aAAa,KAAK,QAAQ,gBAAgB,CAAC;AACrD;AAEO,SAAS,cAAgC;AAC9C,QAAM,MAAM,gBAAgB;AAC5B,MAAI,aAAa,KAAK,CAAC,IAAK,QAAO;AACnC,SAAO,cAAc,GAAG;AAC1B;AAEO,SAAS,2BAAqD;AACnE,QAAM,OAAO,YAAY;AACzB,MAAI,CAAC,KAAM,QAAO;AAElB,QAAM,MAAM,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AACxC,SAAO;AAAA,IACL,YAAY;AAAA,IACZ,KAAK,KAAK;AAAA,IACV,UAAU,KAAK;AAAA,IACf,mBAAmB,KAAK;AAAA,IACxB,MAAM,KAAK;AAAA,IACX,kBAAkB,KAAK;AAAA,IACvB,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK,MAAM,KAAK,KAAK,KAAK;AAAA,EAC5B;AACF;AAEA,SAAS,kBAAsC;AAC7C,MAAI;AACF,WAAO,QAAQ,IAAI;AAAA,EACrB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,eAAwB;AAC/B,MAAI;AACF,WAAO,QAAQ,IAAI,aAAa;AAAA,EAClC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,cAAc,KAAwB;AAC7C,MAAI;AACJ,MAAI;AACF,WAAO,KAAK,MAAM,GAAG;AAAA,EACvB,QAAQ;AACN,UAAM,IAAI;AAAA,MACR,GAAG,wBAAwB;AAAA,IAC7B;AAAA,EACF;AAEA,MAAI,CAAC,SAAS,IAAI,GAAG;AACnB,UAAM,IAAI,MAAM,GAAG,wBAAwB,wBAAwB;AAAA,EACrE;AAEA,SAAO;AAAA,IACL,KAAK,mBAAmB,KAAK,KAAK,cAAc,KAAK;AAAA,IACrD,UAAU,mBAAmB,KAAK,UAAU,cAAc,UAAU;AAAA,IACpE,iBAAiB;AAAA,MACf,KAAK,mBAAmB,KAAK;AAAA,MAC7B;AAAA,MACA;AAAA,IACF;AAAA,IACA,MAAM,iBAAiB,KAAK,MAAM,OAAO;AAAA,IACzC,iBAAiB;AAAA,MACf,KAAK,mBAAmB,KAAK;AAAA,MAC7B,CAAC,GAAG;AAAA,MACJ;AAAA,IACF;AAAA,EACF;AACF;AAEA,SAAS,SAAS,OAAkD;AAClE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAEA,SAAS,mBACP,OACA,UACA,OACQ;AACR,MAAI,UAAU,UAAa,UAAU,KAAM,QAAO;AAClD,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,IAAI,MAAM,GAAG,wBAAwB,IAAI,KAAK,mBAAmB;AACzE;AAEA,SAAS,iBACP,OACA,UACmB;AACnB,MAAI,UAAU,UAAa,UAAU,KAAM,QAAO;AAClD,MAAI,UAAU,UAAU,UAAU,QAAS,QAAO;AAClD,QAAM,IAAI;AAAA,IACR,GAAG,wBAAwB;AAAA,EAC7B;AACF;AAEA,SAAS,wBACP,OACA,UACA,OACU;AACV,MAAI,UAAU,UAAa,UAAU,KAAM,QAAO;AAClD,MACE,MAAM,QAAQ,KAAK,KACnB,MAAM,MAAM,CAAC,SAAS,OAAO,SAAS,QAAQ,GAC9C;AACA,WAAO;AAAA,EACT;AACA,QAAM,IAAI;AAAA,IACR,GAAG,wBAAwB,IAAI,KAAK;AAAA,EACtC;AACF;","names":[]}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
-
import { a as LemonUser } from '../
|
|
2
|
+
import { a as LemonUser } from '../url-qhLR3uNu.js';
|
|
3
|
+
export { l as loginUrl, b as logout, p as profileUrl, r as refreshToken } from '../url-qhLR3uNu.js';
|
|
3
4
|
import * as react from 'react';
|
|
4
|
-
import 'jose';
|
|
5
5
|
|
|
6
6
|
interface AuthContextValue {
|
|
7
7
|
user: LemonUser | null;
|
|
@@ -36,13 +36,4 @@ declare function SessionRestore({ fallback }: {
|
|
|
36
36
|
fallback: React.ReactNode;
|
|
37
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;
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
declare function profileUrl(redirectUrl?: string): string;
|
|
41
|
-
/**
|
|
42
|
-
* 로그아웃. 쿠키 삭제는 응답의 Set-Cookie 가 한다 — 세션 쿠키는 httpOnly
|
|
43
|
-
* 라서 JS 로는 지울 수 없다.
|
|
44
|
-
*/
|
|
45
|
-
declare function logout(): Promise<boolean>;
|
|
46
|
-
declare function refreshToken(): Promise<boolean>;
|
|
47
|
-
|
|
48
|
-
export { AuthProvider, LemonUser, SessionRestore, loginUrl, logout, profileUrl, refreshToken, useAuth };
|
|
39
|
+
export { AuthProvider, LemonUser, SessionRestore, useAuth };
|
package/dist/client/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
} from "../chunk-
|
|
3
|
+
loginUrl,
|
|
4
|
+
logout,
|
|
5
|
+
profileUrl,
|
|
6
|
+
refreshSession,
|
|
7
|
+
refreshToken
|
|
8
|
+
} from "../chunk-ICPDIWYK.js";
|
|
9
|
+
import "../chunk-JIXVTKLG.js";
|
|
9
10
|
|
|
10
11
|
// src/client/context.tsx
|
|
11
12
|
import { createContext, useContext } from "react";
|
|
@@ -27,71 +28,6 @@ function useAuth() {
|
|
|
27
28
|
// src/client/session-restore.tsx
|
|
28
29
|
import { useEffect, useState } from "react";
|
|
29
30
|
import { useSearchParams } from "next/navigation";
|
|
30
|
-
|
|
31
|
-
// src/client/url.ts
|
|
32
|
-
function loginUrl(redirectUrl) {
|
|
33
|
-
if (isMockAuthEnabled()) return redirectUrl;
|
|
34
|
-
assertValidLoginRedirectUrl(redirectUrl);
|
|
35
|
-
return `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;
|
|
36
|
-
}
|
|
37
|
-
function profileUrl(redirectUrl) {
|
|
38
|
-
if (isMockAuthEnabled()) return redirectUrl ?? "/";
|
|
39
|
-
if (redirectUrl) {
|
|
40
|
-
return `${PROFILE_PAGE_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;
|
|
41
|
-
}
|
|
42
|
-
return PROFILE_PAGE_URL;
|
|
43
|
-
}
|
|
44
|
-
async function logout() {
|
|
45
|
-
if (isMockAuthEnabled()) return true;
|
|
46
|
-
try {
|
|
47
|
-
const res = await fetch(LOGOUT_URL, {
|
|
48
|
-
method: "POST",
|
|
49
|
-
credentials: "include"
|
|
50
|
-
});
|
|
51
|
-
return res.ok;
|
|
52
|
-
} catch {
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
async function refreshSession() {
|
|
57
|
-
if (isMockAuthEnabled()) return "ok";
|
|
58
|
-
try {
|
|
59
|
-
const res = await fetch(REFRESH_URL, {
|
|
60
|
-
method: "POST",
|
|
61
|
-
credentials: "include"
|
|
62
|
-
});
|
|
63
|
-
if (res.ok) return "ok";
|
|
64
|
-
if (res.status === 401 || res.status === 403) return "unauthorized";
|
|
65
|
-
return "unavailable";
|
|
66
|
-
} catch {
|
|
67
|
-
return "unavailable";
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
async function refreshToken() {
|
|
71
|
-
return await refreshSession() === "ok";
|
|
72
|
-
}
|
|
73
|
-
function assertValidLoginRedirectUrl(redirectUrl) {
|
|
74
|
-
let url;
|
|
75
|
-
try {
|
|
76
|
-
url = new URL(redirectUrl);
|
|
77
|
-
} catch {
|
|
78
|
-
throw new Error("loginUrl redirectUrl must be a valid absolute URL");
|
|
79
|
-
}
|
|
80
|
-
if (url.protocol !== "https:") {
|
|
81
|
-
throw new Error("loginUrl redirectUrl must use https");
|
|
82
|
-
}
|
|
83
|
-
if (!isAllowedLoginRedirectHost(url.hostname)) {
|
|
84
|
-
throw new Error(
|
|
85
|
-
"loginUrl redirectUrl host must be lemondouble.com or a subdomain of lemondouble.com"
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
function isAllowedLoginRedirectHost(hostname) {
|
|
90
|
-
const normalized = hostname.toLowerCase();
|
|
91
|
-
return normalized === "lemondouble.com" || normalized.endsWith(".lemondouble.com");
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// src/client/session-restore.tsx
|
|
95
31
|
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
96
32
|
function SessionRestore({ fallback }) {
|
|
97
33
|
const searchParams = useSearchParams();
|
package/dist/client/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/client/context.tsx","../../src/client/session-restore.tsx"
|
|
1
|
+
{"version":3,"sources":["../../src/client/context.tsx","../../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 { 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"],"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;AAkD1B,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
|
@@ -1,26 +1,14 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
|
|
3
|
-
declare const DEFAULT_AUTH_BYPASS_PATHS: string[];
|
|
4
3
|
declare const DEFAULT_API_PATHS: string[];
|
|
5
|
-
/** 앱이 <SessionRestore /> 를 마운트해야 하는 기본 경로. */
|
|
6
|
-
declare const DEFAULT_RESTORE_PATH = "/auth/restore";
|
|
7
|
-
declare const PROXY_AUTH_ERROR: {
|
|
8
|
-
readonly UNAUTHORIZED: "UNAUTHORIZED";
|
|
9
|
-
readonly FORBIDDEN: "FORBIDDEN";
|
|
10
|
-
};
|
|
11
|
-
type ProxyAuthErrorCode = (typeof PROXY_AUTH_ERROR)[keyof typeof PROXY_AUTH_ERROR];
|
|
12
4
|
type LoginRedirectUrl = string | ((request: NextRequest) => string);
|
|
13
5
|
interface AuthProxyOptions {
|
|
14
6
|
/** 로그인 없이 열어둘 경로. 익명이면 통과, 만료 세션이면 복구를 거친다. */
|
|
15
7
|
publicPaths?: string[];
|
|
16
|
-
/** 프록시 자체를 건너뛸 경로. 정적 파일 등. */
|
|
17
|
-
bypassPaths?: string[];
|
|
18
8
|
/** 리다이렉트 대신 401 JSON 을 돌려줄 경로. 기본 `/api/*`. */
|
|
19
9
|
apiPaths?: string[];
|
|
20
10
|
loginRedirectUrl?: LoginRedirectUrl;
|
|
21
|
-
/** <SessionRestore /> 를 마운트한 경로. 기본 `/auth/restore`. */
|
|
22
|
-
restorePath?: string;
|
|
23
11
|
}
|
|
24
12
|
declare function createAuthProxy(options?: AuthProxyOptions): (request: NextRequest) => Promise<NextResponse>;
|
|
25
13
|
|
|
26
|
-
export { type AuthProxyOptions, DEFAULT_API_PATHS,
|
|
14
|
+
export { type AuthProxyOptions, DEFAULT_API_PATHS, type LoginRedirectUrl, createAuthProxy };
|
package/dist/proxy/index.js
CHANGED
|
@@ -3,12 +3,12 @@ import {
|
|
|
3
3
|
LOGIN_URL,
|
|
4
4
|
REFRESH_TOKEN_COOKIE,
|
|
5
5
|
isMockAuthEnabled
|
|
6
|
-
} from "../chunk-
|
|
6
|
+
} from "../chunk-JIXVTKLG.js";
|
|
7
7
|
|
|
8
8
|
// src/proxy/index.ts
|
|
9
9
|
import { NextResponse } from "next/server";
|
|
10
10
|
var REFRESH_BUFFER_SECONDS = 60;
|
|
11
|
-
var
|
|
11
|
+
var AUTH_BYPASS_PATHS = [
|
|
12
12
|
"/sw.js",
|
|
13
13
|
"/service-worker.js",
|
|
14
14
|
"/manifest.webmanifest",
|
|
@@ -23,26 +23,19 @@ var DEFAULT_AUTH_BYPASS_PATHS = [
|
|
|
23
23
|
"/icons/*"
|
|
24
24
|
];
|
|
25
25
|
var DEFAULT_API_PATHS = ["/api/*"];
|
|
26
|
-
var
|
|
27
|
-
var PROXY_AUTH_ERROR = {
|
|
28
|
-
UNAUTHORIZED: "UNAUTHORIZED",
|
|
29
|
-
FORBIDDEN: "FORBIDDEN"
|
|
30
|
-
};
|
|
26
|
+
var RESTORE_PATH = "/auth/restore";
|
|
31
27
|
function createAuthProxy(options = {}) {
|
|
32
28
|
const {
|
|
33
29
|
publicPaths = [],
|
|
34
|
-
bypassPaths = [],
|
|
35
30
|
apiPaths = DEFAULT_API_PATHS,
|
|
36
|
-
loginRedirectUrl
|
|
37
|
-
restorePath = DEFAULT_RESTORE_PATH
|
|
31
|
+
loginRedirectUrl
|
|
38
32
|
} = options;
|
|
39
|
-
const effectiveBypassPaths = [...DEFAULT_AUTH_BYPASS_PATHS, ...bypassPaths];
|
|
40
33
|
return async function proxy(request) {
|
|
41
34
|
const { pathname } = request.nextUrl;
|
|
42
|
-
if (pathname ===
|
|
35
|
+
if (pathname === RESTORE_PATH) {
|
|
43
36
|
return NextResponse.next();
|
|
44
37
|
}
|
|
45
|
-
if (isPublicPath(pathname,
|
|
38
|
+
if (isPublicPath(pathname, AUTH_BYPASS_PATHS)) {
|
|
46
39
|
return NextResponse.next();
|
|
47
40
|
}
|
|
48
41
|
if (isMockAuthEnabled()) {
|
|
@@ -58,7 +51,7 @@ function createAuthProxy(options = {}) {
|
|
|
58
51
|
return NextResponse.next();
|
|
59
52
|
}
|
|
60
53
|
return NextResponse.json(
|
|
61
|
-
{ code:
|
|
54
|
+
{ code: "UNAUTHORIZED" },
|
|
62
55
|
{ status: 401 }
|
|
63
56
|
);
|
|
64
57
|
}
|
|
@@ -66,7 +59,7 @@ function createAuthProxy(options = {}) {
|
|
|
66
59
|
return NextResponse.next();
|
|
67
60
|
}
|
|
68
61
|
if (request.cookies.get(REFRESH_TOKEN_COOKIE)?.value) {
|
|
69
|
-
const restore = new URL(
|
|
62
|
+
const restore = new URL(RESTORE_PATH, request.url);
|
|
70
63
|
restore.searchParams.set("next", pathname + request.nextUrl.search);
|
|
71
64
|
return NextResponse.redirect(restore);
|
|
72
65
|
}
|
|
@@ -106,9 +99,6 @@ function redirectToLogin(request, loginRedirectUrl) {
|
|
|
106
99
|
}
|
|
107
100
|
export {
|
|
108
101
|
DEFAULT_API_PATHS,
|
|
109
|
-
DEFAULT_AUTH_BYPASS_PATHS,
|
|
110
|
-
DEFAULT_RESTORE_PATH,
|
|
111
|
-
PROXY_AUTH_ERROR,
|
|
112
102
|
createAuthProxy
|
|
113
103
|
};
|
|
114
104
|
//# sourceMappingURL=index.js.map
|
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 * 프록시는 라우팅 결정만 한다: 신선하면 통과, 아니면 복구 경로로.\n *\n * Next.js 공식 인증 가이드의 optimistic check 다. 프리페치를 포함한 모든\n * 요청에서 돌기 때문에 쿠키의 exp 만 읽고, 네트워크 · DB · 서명 검증을\n * 하지 않는다. 보안 판단은 DAL(getSession / requireAuth)이 한다.\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 * 갱신 주체는 RESTORE_PATH 에 마운트된 <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\nconst 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 /> 를 마운트해야 하는 경로. */\nconst RESTORE_PATH = \"/auth/restore\";\n\nexport type LoginRedirectUrl = string | ((request: NextRequest) => string);\n\nexport interface AuthProxyOptions {\n /** 로그인 없이 열어둘 경로. 익명이면 통과, 만료 세션이면 복구를 거친다. */\n publicPaths?: string[];\n /** 리다이렉트 대신 401 JSON 을 돌려줄 경로. 기본 `/api/*`. */\n apiPaths?: string[];\n loginRedirectUrl?: LoginRedirectUrl;\n}\n\nexport function createAuthProxy(options: AuthProxyOptions = {}) {\n const {\n publicPaths = [],\n apiPaths = DEFAULT_API_PATHS,\n loginRedirectUrl,\n } = options;\n\n return async function proxy(request: NextRequest): Promise<NextResponse> {\n const { pathname } = request.nextUrl;\n\n // 복구 경로 자신은 항상 통과. 여기서 복구 경로로 보내면 무한 루프다.\n if (pathname === RESTORE_PATH) {\n return NextResponse.next();\n }\n\n if (isPublicPath(pathname, AUTH_BYPASS_PATHS)) {\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: \"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(RESTORE_PATH, 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":";;;;;;;;AAAA,SAAsB,oBAAoB;AA2B1C,IAAM,yBAAyB;AAE/B,IAAM,oBAAoB;AAAA,EACxB;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;AAG1C,IAAM,eAAe;AAYd,SAAS,gBAAgB,UAA4B,CAAC,GAAG;AAC9D,QAAM;AAAA,IACJ,cAAc,CAAC;AAAA,IACf,WAAW;AAAA,IACX;AAAA,EACF,IAAI;AAEJ,SAAO,eAAe,MAAM,SAA6C;AACvE,UAAM,EAAE,SAAS,IAAI,QAAQ;AAG7B,QAAI,aAAa,cAAc;AAC7B,aAAO,aAAa,KAAK;AAAA,IAC3B;AAEA,QAAI,aAAa,UAAU,iBAAiB,GAAG;AAC7C,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;AAIA,UAAM,WAAW,aAAa,UAAU,WAAW;AAEnD,QAAI,aAAa,UAAU,QAAQ,GAAG;AAGpC,UAAI,UAAU;AACZ,eAAO,aAAa,KAAK;AAAA,MAC3B;AACA,aAAO,aAAa;AAAA,QAClB,EAAE,MAAM,eAAe;AAAA,QACvB,EAAE,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AAIA,QAAI,QAAQ,WAAW,OAAO;AAC5B,aAAO,aAAa,KAAK;AAAA,IAC3B;AAMA,QAAI,QAAQ,QAAQ,IAAI,oBAAoB,GAAG,OAAO;AACpD,YAAM,UAAU,IAAI,IAAI,cAAc,QAAQ,GAAG;AACjD,cAAQ,aAAa,IAAI,QAAQ,WAAW,QAAQ,QAAQ,MAAM;AAClE,aAAO,aAAa,SAAS,OAAO;AAAA,IACtC;AAEA,QAAI,UAAU;AACZ,aAAO,aAAa,KAAK;AAAA,IAC3B;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":[]}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,31 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export {
|
|
3
|
-
import 'jose';
|
|
4
|
-
|
|
5
|
-
declare function verifyAccessToken(): Promise<AccessTokenClaims | null>;
|
|
6
|
-
declare function verifyAccessTokenString(token: string): Promise<AccessTokenClaims | null>;
|
|
1
|
+
import { L as LemonSession, a as LemonUser } from '../url-qhLR3uNu.js';
|
|
2
|
+
export { l as loginUrl, p as profileUrl } from '../url-qhLR3uNu.js';
|
|
7
3
|
|
|
8
4
|
declare const getUser: () => Promise<LemonUser | null>;
|
|
9
|
-
|
|
10
|
-
clientId
|
|
11
|
-
}
|
|
12
|
-
declare function
|
|
13
|
-
declare function requireAuth(redirectTo?: string): Promise<LemonUser>;
|
|
14
|
-
interface RequireClientOptions {
|
|
5
|
+
declare function getSession({ clientId, }: {
|
|
6
|
+
clientId: string;
|
|
7
|
+
}): Promise<LemonSession>;
|
|
8
|
+
declare function requireClient(clientId: string, { loginRedirectTo, unapprovedRedirectTo, }?: {
|
|
15
9
|
loginRedirectTo?: string;
|
|
16
10
|
unapprovedRedirectTo?: string;
|
|
17
|
-
}
|
|
18
|
-
declare function requireClient(clientId: string, optionsOrRedirectTo?: string | RequireClientOptions): Promise<LemonUser>;
|
|
19
|
-
|
|
20
|
-
declare const AUTH_SERVER_URL = "https://auth.lemondouble.com";
|
|
21
|
-
declare const JWKS_URL = "https://auth.lemondouble.com/.well-known/jwks.json";
|
|
22
|
-
declare const LOGIN_URL = "https://auth.lemondouble.com/api/oauth2/google/login";
|
|
23
|
-
declare const REFRESH_URL = "https://auth.lemondouble.com/api/token/refresh";
|
|
24
|
-
declare const LOGOUT_URL = "https://auth.lemondouble.com/api/token/logout";
|
|
25
|
-
declare const PROFILE_URL = "https://auth.lemondouble.com/api/user/me";
|
|
26
|
-
declare const PROFILE_PAGE_URL = "https://auth.lemondouble.com/profile";
|
|
27
|
-
declare const ACCESS_TOKEN_COOKIE = "lemon_access_token";
|
|
28
|
-
declare const REFRESH_TOKEN_COOKIE = "lemon_refresh_token";
|
|
29
|
-
declare const DEVICE_ID_COOKIE = "device_id";
|
|
11
|
+
}): Promise<LemonUser>;
|
|
30
12
|
|
|
31
|
-
export {
|
|
13
|
+
export { LemonSession, LemonUser, getSession, getUser, requireClient };
|
package/dist/server/index.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
import {
|
|
2
|
+
loginUrl,
|
|
3
|
+
profileUrl
|
|
4
|
+
} from "../chunk-ICPDIWYK.js";
|
|
1
5
|
import {
|
|
2
6
|
ACCESS_TOKEN_COOKIE,
|
|
3
7
|
AUTH_SERVER_URL,
|
|
4
|
-
DEVICE_ID_COOKIE,
|
|
5
8
|
JWKS_URL,
|
|
6
|
-
LOGIN_URL,
|
|
7
|
-
LOGOUT_URL,
|
|
8
|
-
PROFILE_PAGE_URL,
|
|
9
|
-
PROFILE_URL,
|
|
10
|
-
REFRESH_TOKEN_COOKIE,
|
|
11
|
-
REFRESH_URL,
|
|
12
9
|
getMockAccessTokenClaims
|
|
13
|
-
} from "../chunk-
|
|
10
|
+
} from "../chunk-JIXVTKLG.js";
|
|
11
|
+
|
|
12
|
+
// src/server/user.ts
|
|
13
|
+
import { cache } from "react";
|
|
14
|
+
import { redirect } from "next/navigation";
|
|
14
15
|
|
|
15
16
|
// src/server/verify.ts
|
|
16
17
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
@@ -45,12 +46,16 @@ function errorCode(error) {
|
|
|
45
46
|
|
|
46
47
|
// src/server/verify.ts
|
|
47
48
|
var jwks = createRemoteJWKSet(new URL(JWKS_URL));
|
|
48
|
-
async function
|
|
49
|
+
async function verifyAccessToken() {
|
|
50
|
+
const mockClaims = getMockAccessTokenClaims();
|
|
51
|
+
if (mockClaims) return mockClaims;
|
|
52
|
+
const cookieStore = await cookies();
|
|
53
|
+
const token = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
|
|
54
|
+
if (!token) return null;
|
|
49
55
|
try {
|
|
50
56
|
const { payload } = await jwtVerify(token, jwks, {
|
|
51
57
|
issuer: AUTH_SERVER_URL,
|
|
52
|
-
// 발급자(자택 k3s)와 검증자(Vercel)의
|
|
53
|
-
// 발급된 토큰이 nbf 미래로 거부되어 로그인 직후 로그아웃으로 보인다.
|
|
58
|
+
// 발급자(자택 k3s)와 검증자(Vercel)의 시계 어긋남 허용
|
|
54
59
|
clockTolerance: 10
|
|
55
60
|
});
|
|
56
61
|
if (isAccessTokenClaims(payload)) return payload;
|
|
@@ -64,38 +69,6 @@ async function verify(token) {
|
|
|
64
69
|
return null;
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
|
-
async function verifyAccessToken() {
|
|
68
|
-
const mockClaims = getMockAccessTokenClaims();
|
|
69
|
-
if (mockClaims) return mockClaims;
|
|
70
|
-
const cookieStore = await cookies();
|
|
71
|
-
const token = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
|
|
72
|
-
if (!token) return null;
|
|
73
|
-
return verify(token);
|
|
74
|
-
}
|
|
75
|
-
async function verifyAccessTokenString(token) {
|
|
76
|
-
const mockClaims = getMockAccessTokenClaims();
|
|
77
|
-
if (mockClaims) return mockClaims;
|
|
78
|
-
return verify(token);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// src/server/user.ts
|
|
82
|
-
import { cache } from "react";
|
|
83
|
-
import { redirect } from "next/navigation";
|
|
84
|
-
|
|
85
|
-
// src/approval.ts
|
|
86
|
-
var warnedMissingClientId = false;
|
|
87
|
-
function isApprovedClient(approvedClients, clientId) {
|
|
88
|
-
if (!clientId) {
|
|
89
|
-
if (!warnedMissingClientId) {
|
|
90
|
-
warnedMissingClientId = true;
|
|
91
|
-
warnAuth(
|
|
92
|
-
"clientId not provided \u2014 approval check is disabled (all users pass). \uC124\uC815 \uB204\uB77D\uC774 \uC544\uB2CC\uC9C0 \uD655\uC778\uD560 \uAC83."
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
return true;
|
|
96
|
-
}
|
|
97
|
-
return approvedClients.includes(clientId) || approvedClients.includes("*");
|
|
98
|
-
}
|
|
99
72
|
|
|
100
73
|
// src/server/user.ts
|
|
101
74
|
function claimsToUser(claims) {
|
|
@@ -122,43 +95,29 @@ var getSessionByClientId = cache(
|
|
|
122
95
|
return { type: "authenticated", user };
|
|
123
96
|
}
|
|
124
97
|
);
|
|
125
|
-
async function getSession(
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const user = await getUser();
|
|
130
|
-
if (user) return user;
|
|
131
|
-
redirect(redirectTo);
|
|
98
|
+
async function getSession({
|
|
99
|
+
clientId
|
|
100
|
+
}) {
|
|
101
|
+
return getSessionByClientId(clientId);
|
|
132
102
|
}
|
|
133
|
-
async function requireClient(clientId,
|
|
134
|
-
|
|
103
|
+
async function requireClient(clientId, {
|
|
104
|
+
loginRedirectTo = "/",
|
|
105
|
+
unapprovedRedirectTo = `${AUTH_SERVER_URL}/error?code=FORBIDDEN`
|
|
106
|
+
} = {}) {
|
|
135
107
|
const session = await getSession({ clientId });
|
|
136
|
-
if (session.type === "none")
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
if (session.type === "unapproved") {
|
|
140
|
-
redirect(
|
|
141
|
-
options.unapprovedRedirectTo ?? `${AUTH_SERVER_URL}/error?code=FORBIDDEN`
|
|
142
|
-
);
|
|
143
|
-
}
|
|
108
|
+
if (session.type === "none") redirect(loginRedirectTo);
|
|
109
|
+
if (session.type === "unapproved") redirect(unapprovedRedirectTo);
|
|
144
110
|
return session.user;
|
|
145
111
|
}
|
|
112
|
+
function isApprovedClient(approvedClients, clientId) {
|
|
113
|
+
if (!clientId) return false;
|
|
114
|
+
return approvedClients.includes(clientId) || approvedClients.includes("*");
|
|
115
|
+
}
|
|
146
116
|
export {
|
|
147
|
-
ACCESS_TOKEN_COOKIE,
|
|
148
|
-
AUTH_SERVER_URL,
|
|
149
|
-
DEVICE_ID_COOKIE,
|
|
150
|
-
JWKS_URL,
|
|
151
|
-
LOGIN_URL,
|
|
152
|
-
LOGOUT_URL,
|
|
153
|
-
PROFILE_PAGE_URL,
|
|
154
|
-
PROFILE_URL,
|
|
155
|
-
REFRESH_TOKEN_COOKIE,
|
|
156
|
-
REFRESH_URL,
|
|
157
117
|
getSession,
|
|
158
118
|
getUser,
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
verifyAccessTokenString
|
|
119
|
+
loginUrl,
|
|
120
|
+
profileUrl,
|
|
121
|
+
requireClient
|
|
163
122
|
};
|
|
164
123
|
//# sourceMappingURL=index.js.map
|
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"],"sourcesContent":["import { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport { cookies } from \"next/headers\";\nimport type { AccessTokenClaims } from \"../types.js\";\nimport { JWKS_URL, AUTH_SERVER_URL, ACCESS_TOKEN_COOKIE } from \"../constants.js\";\nimport { isAccessTokenClaims } from \"../claims.js\";\nimport { getMockAccessTokenClaims } from \"../mock.js\";\nimport { warnAuth, errorCode } from \"../log.js\";\n\nconst jwks = createRemoteJWKSet(new URL(JWKS_URL));\n\n/**\n * 검증과 진단을 한 곳에 모은다.\n *\n * 예전에는 아래 두 export 가 같은 try/catch 를 각자 갖고 있었다. 그러면\n * 로깅을 붙일 때도 벌마다 갈리고, 한쪽만 고치는 일이 생긴다.\n */\nasync function verify(token: string): Promise<AccessTokenClaims | null> {\n try {\n const { payload } = await jwtVerify(token, jwks, {\n issuer: AUTH_SERVER_URL,\n // 발급자(자택 k3s)와 검증자(Vercel)의 시계가 몇 초 어긋나면, 방금\n // 발급된 토큰이 nbf 미래로 거부되어 로그인 직후 로그아웃으로 보인다.\n clockTolerance: 10,\n });\n if (isAccessTokenClaims(payload)) return payload;\n warnAuth(\"access token claims shape mismatch\");\n return null;\n } catch (error) {\n // 정상 만료는 갱신으로 이어지는 흔한 경로라 남기지 않는다. 나머지는\n // 원인이 전혀 다르다 — 특히 ERR_JWKS_NO_MATCHING_KEY 는 키 로테이션이나\n // JWKS 도달 실패이고, 이걸 위조 토큰과 뭉개면 전 사용자가 조용히\n // 로그아웃된 이유를 알 수 없게 된다.\n const code = errorCode(error);\n if (code !== \"ERR_JWT_EXPIRED\") {\n warnAuth(\"access token verification failed\", { code });\n }\n return null;\n }\n}\n\nexport async function verifyAccessToken(): Promise<AccessTokenClaims | null> {\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) return mockClaims;\n\n const cookieStore = await cookies();\n const token = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;\n if (!token) return null;\n\n return verify(token);\n}\n\nexport async function verifyAccessTokenString(\n token: string\n): Promise<AccessTokenClaims | null> {\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) return mockClaims;\n\n return verify(token);\n}\n","import type { JWTPayload } from \"jose\";\nimport type { AccessTokenClaims } from \"./types.js\";\n\nexport function isAccessTokenClaims(\n payload: JWTPayload\n): payload is AccessTokenClaims {\n return (\n payload.token_type === \"access\" &&\n typeof payload.sub === \"string\" &&\n typeof payload.nickname === \"string\" &&\n typeof payload.profile_image_url === \"string\" &&\n (payload.role === \"user\" || payload.role === \"admin\") &&\n Array.isArray(payload.approved_clients) &&\n payload.approved_clients.every((clientId) => typeof clientId === \"string\")\n );\n}\n","/**\n * 진단 로그.\n *\n * 이 라이브러리는 오랫동안 실패를 전부 `null` 하나로만 알렸다. 2026-07-19 에\n * auth 서버 앞의 Cloudflare Bot Fight Mode 가 프록시의 갱신 요청을 403\n * (`cf-mitigated: challenge`) 으로 막았을 때, 남은 흔적이 하나도 없어서\n * 원인을 찾는 데 반나절이 걸렸다. 앱 쪽에 임시 진단 코드를 심고 프로덕션에\n * 두 번 배포한 뒤에야 응답 코드를 볼 수 있었다.\n *\n * 그래서 실패에는 최소한의 사실을 남긴다.\n *\n * **토큰 값은 절대 남기지 않는다.** JWT 는 base64url 이라 \"접두 8자\" 같은\n * 안전해 보이는 일부만으로도 헤더와 payload 앞부분이 그대로 복원된다.\n * `sub`, `nickname`, `approved_clients` 가 거기 들어 있다. 남길 수 있는 것은\n * 쿠키 **이름**, HTTP 상태 코드, 에러 코드처럼 값이 아닌 사실뿐이다.\n */\n\nconst PREFIX = \"[lemon-auth]\";\n\nexport function warnAuth(\n event: string,\n detail?: Record<string, unknown>\n): void {\n if (detail === undefined) {\n console.warn(`${PREFIX} ${event}`);\n return;\n }\n console.warn(`${PREFIX} ${event}`, JSON.stringify(detail));\n}\n\n/**\n * 에러에서 값 노출 없이 식별자만 뽑는다.\n *\n * jose 는 `ERR_JWKS_NO_MATCHING_KEY`(키 로테이션), `ERR_JWT_EXPIRED`(정상 만료),\n * `ERR_JWS_SIGNATURE_VERIFICATION_FAILED`(위조 또는 설정 오류) 처럼 원인이\n * 전혀 다른 실패를 구분 가능한 `code` 로 준다. 이걸 뭉개면 JWKS 장애와\n * 위조 토큰이 같은 증상으로 보인다.\n *\n * undici 의 네트워크 실패는 `cause.code` 에 `ENOTFOUND` 같은 값이 들어간다.\n */\nexport function errorCode(error: unknown): string {\n if (typeof error === \"object\" && error !== null) {\n const { code, cause } = error as { code?: unknown; cause?: unknown };\n if (typeof code === \"string\") return code;\n if (typeof cause === \"object\" && cause !== null) {\n const causeCode = (cause as { code?: unknown }).code;\n if (typeof causeCode === \"string\") return causeCode;\n }\n }\n if (error instanceof Error) return error.name;\n return \"unknown\";\n}\n","import { cache } from \"react\";\nimport { redirect } from \"next/navigation\";\nimport type { LemonSession, LemonUser } from \"../types.js\";\nimport { AUTH_SERVER_URL } from \"../constants.js\";\nimport { isApprovedClient } from \"../approval.js\";\nimport { verifyAccessToken } from \"./verify.js\";\n\nfunction claimsToUser(\n claims: NonNullable<Awaited<ReturnType<typeof verifyAccessToken>>>\n): LemonUser {\n return {\n uid: claims.sub,\n nickname: claims.nickname,\n profileImageUrl: claims.profile_image_url,\n role: claims.role,\n approvedClients: claims.approved_clients,\n };\n}\n\nexport const getUser = cache(async (): Promise<LemonUser | null> => {\n const claims = await verifyAccessToken();\n if (!claims) return null;\n return claimsToUser(claims);\n});\n\nexport interface GetSessionOptions {\n clientId?: string;\n}\n\nconst getSessionByClientId = cache(\n async (clientId?: string): Promise<LemonSession> => {\n const user = await getUser();\n if (!user) return { type: \"none\" };\n if (!isApprovedClient(user.approvedClients, clientId)) {\n return { type: \"unapproved\", user };\n }\n return { type: \"authenticated\", user };\n }\n);\n\nexport async function getSession(\n options: GetSessionOptions = {}\n): Promise<LemonSession> {\n return getSessionByClientId(options.clientId);\n}\n\nexport async function requireAuth(redirectTo = \"/\"): Promise<LemonUser> {\n const user = await getUser();\n if (user) return user;\n redirect(redirectTo);\n}\n\nexport interface RequireClientOptions {\n loginRedirectTo?: string;\n unapprovedRedirectTo?: string;\n}\n\nexport async function requireClient(\n clientId: string,\n optionsOrRedirectTo: string | RequireClientOptions = \"/\"\n): Promise<LemonUser> {\n const options =\n typeof optionsOrRedirectTo === \"string\"\n ? { loginRedirectTo: optionsOrRedirectTo }\n : optionsOrRedirectTo;\n const session = await getSession({ clientId });\n if (session.type === \"none\") {\n redirect(options.loginRedirectTo ?? \"/\");\n }\n if (session.type === \"unapproved\") {\n redirect(\n options.unapprovedRedirectTo ??\n `${AUTH_SERVER_URL}/error?code=FORBIDDEN`\n );\n }\n return session.user;\n}\n","import { warnAuth } from \"./log.js\";\n\n/** fail-open 경고를 요청마다 찍지 않기 위한 플래그. */\nlet warnedMissingClientId = false;\n\nexport function isApprovedClient(\n approvedClients: string[],\n clientId?: string\n): boolean {\n if (!clientId) {\n // clientId 를 안 넘기면 승인 검사가 통째로 열린다(fail-open). 이 라이브러리를\n // clientId 없이 쓰는 앱도 있으므로 동작은 유지하지만, 환경변수가 배포에서\n // 빠져서 이렇게 된 경우와 구분이 안 된다. 후자는 인증이 사실상 없는 상태라\n // 조용히 넘어가면 안 된다.\n if (!warnedMissingClientId) {\n warnedMissingClientId = true;\n warnAuth(\n \"clientId not provided — approval check is disabled (all users pass). \" +\n \"설정 누락이 아닌지 확인할 것.\"\n );\n }\n return true;\n }\n return approvedClients.includes(clientId) || approvedClients.includes(\"*\");\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/server/user.ts","../../src/server/verify.ts","../../src/claims.ts","../../src/log.ts"],"sourcesContent":["import { cache } from \"react\";\nimport { redirect } from \"next/navigation\";\nimport type { AccessTokenClaims, LemonSession, LemonUser } from \"../types.js\";\nimport { AUTH_SERVER_URL } from \"../constants.js\";\nimport { verifyAccessToken } from \"./verify.js\";\n\nfunction claimsToUser(claims: AccessTokenClaims): 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\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 clientId,\n}: {\n clientId: string;\n}): Promise<LemonSession> {\n return getSessionByClientId(clientId);\n}\n\nexport async function requireClient(\n clientId: string,\n {\n loginRedirectTo = \"/\",\n unapprovedRedirectTo = `${AUTH_SERVER_URL}/error?code=FORBIDDEN`,\n }: { loginRedirectTo?: string; unapprovedRedirectTo?: string } = {}\n): Promise<LemonUser> {\n const session = await getSession({ clientId });\n if (session.type === \"none\") redirect(loginRedirectTo);\n if (session.type === \"unapproved\") redirect(unapprovedRedirectTo);\n return session.user;\n}\n\n// clientId 가 비어 있으면(환경변수 누락) 아무도 승인되지 않는다. \"*\" 는 mock user 용이다.\nfunction isApprovedClient(approvedClients: string[], clientId: string): boolean {\n if (!clientId) return false;\n return approvedClients.includes(clientId) || approvedClients.includes(\"*\");\n}\n","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\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 try {\n const { payload } = await jwtVerify(token, jwks, {\n issuer: AUTH_SERVER_URL,\n // 발급자(자택 k3s)와 검증자(Vercel)의 시계 어긋남 허용\n clockTolerance: 10,\n });\n if (isAccessTokenClaims(payload)) return payload;\n warnAuth(\"access token claims shape mismatch\");\n return null;\n } catch (error) {\n // 정상 만료는 흔한 경로라 남기지 않는다. 키 로테이션·JWKS 장애와 위조를 구분하려고 code 를 남긴다.\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","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"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
interface LemonUser {
|
|
2
|
+
uid: string;
|
|
3
|
+
nickname: string;
|
|
4
|
+
profileImageUrl: string;
|
|
5
|
+
role: "user" | "admin";
|
|
6
|
+
approvedClients: string[];
|
|
7
|
+
}
|
|
8
|
+
type LemonSession = {
|
|
9
|
+
type: "none";
|
|
10
|
+
} | {
|
|
11
|
+
type: "unapproved";
|
|
12
|
+
user: LemonUser;
|
|
13
|
+
} | {
|
|
14
|
+
type: "authenticated";
|
|
15
|
+
user: LemonUser;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
declare function loginUrl(redirectUrl: string): string;
|
|
19
|
+
declare function profileUrl(redirectUrl?: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* 로그아웃. 쿠키 삭제는 응답의 Set-Cookie 가 한다 — 세션 쿠키는 httpOnly
|
|
22
|
+
* 라서 JS 로는 지울 수 없다.
|
|
23
|
+
*/
|
|
24
|
+
declare function logout(): Promise<boolean>;
|
|
25
|
+
declare function refreshToken(): Promise<boolean>;
|
|
26
|
+
|
|
27
|
+
export { type LemonSession as L, type LemonUser as a, logout as b, loginUrl as l, profileUrl as p, refreshToken as r };
|
package/package.json
CHANGED
package/dist/types-B5uRY2Si.d.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { JWTPayload } from 'jose';
|
|
2
|
-
|
|
3
|
-
interface AccessTokenClaims extends JWTPayload {
|
|
4
|
-
token_type: "access";
|
|
5
|
-
sub: string;
|
|
6
|
-
nickname: string;
|
|
7
|
-
profile_image_url: string;
|
|
8
|
-
role: "user" | "admin";
|
|
9
|
-
approved_clients: string[];
|
|
10
|
-
}
|
|
11
|
-
interface LemonUser {
|
|
12
|
-
uid: string;
|
|
13
|
-
nickname: string;
|
|
14
|
-
profileImageUrl: string;
|
|
15
|
-
role: "user" | "admin";
|
|
16
|
-
approvedClients: string[];
|
|
17
|
-
}
|
|
18
|
-
type LemonSession = {
|
|
19
|
-
type: "none";
|
|
20
|
-
} | {
|
|
21
|
-
type: "unapproved";
|
|
22
|
-
user: LemonUser;
|
|
23
|
-
} | {
|
|
24
|
-
type: "authenticated";
|
|
25
|
-
user: LemonUser;
|
|
26
|
-
};
|
|
27
|
-
interface UserProfile {
|
|
28
|
-
uid: string;
|
|
29
|
-
nickname: string;
|
|
30
|
-
profile_image_url: string;
|
|
31
|
-
role: "user" | "admin";
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export type { AccessTokenClaims as A, LemonSession as L, UserProfile as U, LemonUser as a };
|