@lemondouble/lemon-auth 0.5.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,24 @@
10
10
  pnpm add @lemondouble/lemon-auth
11
11
  ```
12
12
 
13
+ ## 로컬 개발 Mock User
14
+
15
+ 로컬 개발에서 auth-server, OAuth, JWT 쿠키 없이 로그인 상태를 재현하려면 `.env.local`에 mock user를 설정합니다.
16
+
17
+ ```bash
18
+ NEXT_PUBLIC_LEMON_AUTH_MOCK_USER='{"uid":"local-user","nickname":"Local User","profileImageUrl":"","role":"admin","approvedClients":["*"]}'
19
+ ```
20
+
21
+ `NODE_ENV === "production"`에서는 이 값이 있어도 무시됩니다. `approvedClients`에 `"*"`를 넣으면 모든 `clientId`를 승인된 것으로 처리합니다.
22
+
23
+ Mock User가 활성화되면:
24
+
25
+ - `getUser()`, `getSession()`, `requireAuth()`, `requireClient()`는 mock user를 로그인 유저처럼 반환합니다.
26
+ - `createAuthProxy()`는 mock user를 인증 성공으로 처리하고, `clientId` 승인 체크는 유지합니다.
27
+ - `loginUrl(redirectUrl)`은 `redirectUrl`을 그대로 반환합니다.
28
+ - `profileUrl(redirectUrl?)`은 `redirectUrl ?? "/"`를 반환합니다.
29
+ - `refreshToken()`은 `true`, `logout()`은 no-op 후 `true`를 반환합니다.
30
+
13
31
  ## 엔트리포인트
14
32
 
15
33
  | 경로 | 환경 | 용도 |
@@ -26,7 +44,7 @@ Server Components, Route Handlers, Server Actions에서 사용합니다.
26
44
 
27
45
  ### `verifyAccessToken()`
28
46
 
29
- 쿠키에서 `access_token`을 읽어 JWKS(ES256)로 검증합니다.
47
+ 쿠키에서 `lemon_access_token`을 읽어 JWKS(ES256)로 검증합니다.
30
48
 
31
49
  ```ts
32
50
  import { verifyAccessToken } from "@lemondouble/lemon-auth/server";
@@ -39,6 +57,7 @@ const claims = await verifyAccessToken();
39
57
 
40
58
  ```ts
41
59
  interface AccessTokenClaims {
60
+ token_type: "access"; // access token 여부
42
61
  sub: string; // 사용자 UUID
43
62
  nickname: string; // 닉네임
44
63
  profile_image_url: string; // 프로필 이미지 URL
@@ -98,6 +117,34 @@ interface LemonUser {
98
117
  }
99
118
  ```
100
119
 
120
+ ### `getSession(options?)`
121
+
122
+ 로그인 상태와 클라이언트 승인 상태를 함께 반환합니다.
123
+
124
+ ```ts
125
+ import { getSession } from "@lemondouble/lemon-auth/server";
126
+
127
+ const CLIENT_ID = process.env.CLIENT_ID!;
128
+
129
+ export default async function Page() {
130
+ const session = await getSession({ clientId: CLIENT_ID });
131
+
132
+ if (session.type === "none") return <p>로그인이 필요합니다</p>;
133
+ if (session.type === "unapproved") return <p>관리자 승인이 필요합니다</p>;
134
+
135
+ return <p>{session.user.nickname}님 환영합니다</p>;
136
+ }
137
+ ```
138
+
139
+ 반환되는 `LemonSession`:
140
+
141
+ ```ts
142
+ type LemonSession =
143
+ | { type: "none" }
144
+ | { type: "unapproved"; user: LemonUser }
145
+ | { type: "authenticated"; user: LemonUser };
146
+ ```
147
+
101
148
  ### `requireAuth(redirectTo?)`
102
149
 
103
150
  인증된 유저를 반환합니다. 미인증이면 `redirectTo`로 redirect합니다.
@@ -117,7 +164,7 @@ export default async function ProtectedPage() {
117
164
  |---------|------|--------|------|
118
165
  | `redirectTo` | `string` | `"/"` | 미인증 시 redirect 경로 |
119
166
 
120
- ### `requireClient(clientId, redirectTo?)`
167
+ ### `requireClient(clientId, options?)`
121
168
 
122
169
  `requireAuth()` + `approved_clients` 체크. 관리자가 승인한 사용자만 접근할 수 있는 서비스에서 사용합니다.
123
170
 
@@ -127,8 +174,10 @@ import { requireClient } from "@lemondouble/lemon-auth/server";
127
174
  const CLIENT_ID = process.env.CLIENT_ID!;
128
175
 
129
176
  export default async function Page() {
130
- const user = await requireClient(CLIENT_ID);
131
- // approved_clients에 CLIENT_ID가 없으면 Error("Client not approved") throw
177
+ const user = await requireClient(CLIENT_ID, {
178
+ loginRedirectTo: "/",
179
+ unapprovedRedirectTo: "/pending-approval",
180
+ });
132
181
  return <p>{user.nickname}</p>;
133
182
  }
134
183
  ```
@@ -136,9 +185,10 @@ export default async function Page() {
136
185
  | 파라미터 | 타입 | 기본값 | 설명 |
137
186
  |---------|------|--------|------|
138
187
  | `clientId` | `string` | (필수) | 체크할 클라이언트 UUID |
139
- | `redirectTo` | `string` | `"/"` | 미인증 시 redirect 경로 |
188
+ | `options.loginRedirectTo` | `string` | `"/"` | 미인증 시 redirect 경로 |
189
+ | `options.unapprovedRedirectTo` | `string` | `undefined` | 로그인은 됐지만 클라이언트 미승인 시 redirect 경로. 미설정 시 auth-server `/error?code=FORBIDDEN`로 redirect (proxy 동작과 동일) |
140
190
 
141
- ### `refreshTokenFromCookie(refreshTokenCookie)`
191
+ ### `refreshTokenFromCookie(refreshTokenCookie, deviceIdCookie?)`
142
192
 
143
193
  서버사이드에서 토큰 갱신을 수행합니다. Route Handler 등에서 수동으로 갱신할 때 사용합니다.
144
194
 
@@ -147,10 +197,11 @@ import { refreshTokenFromCookie } from "@lemondouble/lemon-auth/server";
147
197
 
148
198
  export async function POST(request: Request) {
149
199
  const cookieStore = await cookies();
150
- const refreshToken = cookieStore.get("refresh_token")?.value;
200
+ const refreshToken = cookieStore.get("lemon_refresh_token")?.value;
201
+ const deviceId = cookieStore.get("device_id")?.value;
151
202
  if (!refreshToken) return new Response(null, { status: 401 });
152
203
 
153
- const result = await refreshTokenFromCookie(refreshToken);
204
+ const result = await refreshTokenFromCookie(refreshToken, deviceId);
154
205
  // result: { ok: boolean, setCookieHeaders: string[] }
155
206
 
156
207
  if (!result.ok) return new Response(null, { status: 401 });
@@ -176,8 +227,9 @@ import {
176
227
  LOGOUT_URL, // "https://auth.lemondouble.com/api/token/logout"
177
228
  PROFILE_URL, // "https://auth.lemondouble.com/api/user/me"
178
229
  PROFILE_PAGE_URL, // "https://auth.lemondouble.com/profile"
179
- ACCESS_TOKEN_COOKIE, // "access_token"
180
- REFRESH_TOKEN_COOKIE, // "refresh_token"
230
+ ACCESS_TOKEN_COOKIE, // "lemon_access_token"
231
+ REFRESH_TOKEN_COOKIE, // "lemon_refresh_token"
232
+ DEVICE_ID_COOKIE, // "device_id"
181
233
  } from "@lemondouble/lemon-auth/server";
182
234
  ```
183
235
 
@@ -195,12 +247,16 @@ import { createAuthProxy } from "@lemondouble/lemon-auth/proxy";
195
247
 
196
248
  export default createAuthProxy({
197
249
  publicPaths: ["/", "/about", "/api/public/*"],
250
+ bypassPaths: ["/workbox-*"],
198
251
  clientId: process.env.CLIENT_ID,
199
- loginRedirectUrl: "https://myapp.lemondouble.com",
252
+ loginRedirectUrl: (request) => request.url,
253
+ unapprovedRedirectUrl: "/pending-approval",
200
254
  });
201
255
 
202
256
  export const config = {
203
- matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
257
+ matcher: [
258
+ "/((?!_next/static|_next/image|sw.js|manifest.webmanifest|favicon.ico|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico|woff|woff2|ttf|eot|otf)$).*)",
259
+ ],
204
260
  };
205
261
  ```
206
262
 
@@ -209,24 +265,86 @@ export const config = {
209
265
  | 옵션 | 타입 | 기본값 | 설명 |
210
266
  |------|------|--------|------|
211
267
  | `publicPaths` | `string[]` | `[]` | 인증 없이 접근 가능한 경로. `*`로 끝나면 prefix 매칭 (예: `"/api/public/*"`) |
268
+ | `bypassPaths` | `string[]` | `[]` | proxy가 refresh도 하지 않고 바로 통과시킬 추가 경로. 기본 PWA 경로(`/sw.js`, `/manifest.webmanifest` 등)는 항상 bypass |
269
+ | `apiPaths` | `string[]` | `["/api/*"]` | 보호 API 경로. 인증 실패 시 redirect 대신 JSON 응답을 반환한다. disable하려면 `[]` |
212
270
  | `clientId` | `string` | `undefined` | 설정 시 `approved_clients`에 포함 여부를 체크 |
213
- | `loginRedirectUrl` | `string` | `undefined` | 미인증 시 Google 로그인 후 돌아올 URL. 미설정 시 `"/"` 로 redirect |
271
+ | `loginRedirectUrl` | `string \| (request: NextRequest) => string` | `undefined` | 미인증 시 Google 로그인 후 돌아올 URL. deep link 보존이 필요하면 `(request) => request.url` 사용. 미설정 시 `"/"` 로 redirect |
272
+ | `unapprovedRedirectUrl` | `string \| (request: NextRequest) => string` | `undefined` | 로그인은 됐지만 `clientId` 미승인일 때 redirect할 URL. 미설정 시 auth-server `/error?code=FORBIDDEN`로 redirect (무한 loop 방지) |
214
273
  | `onAuthSuccess` | `(claims, request, response) => Promise<NextResponse> \| NextResponse` | `undefined` | 보호 경로에서 인증 성공 시 호출되는 콜백. DB 동기화, 헤더 주입 등 앱별 로직에 사용 |
215
274
 
216
275
  #### 동작 방식
217
276
 
277
+ **Bypass 경로** (기본 PWA/정적 리소스 + `bypassPaths`):
278
+
279
+ 1. `/sw.js`, `/manifest.webmanifest`, `/manifest.json`, `/favicon.ico` 등은 인증/갱신 없이 항상 통과
280
+ 2. 가능하면 `matcher`에서도 제외해 proxy 실행 자체를 피하는 것을 권장
281
+
218
282
  **보호 경로** (`publicPaths`에 해당하지 않는 경로):
219
283
 
220
- 1. `access_token` 쿠키가 있고 만료까지 60초 이상 남았으면 → JWKS 검증 → `onAuthSuccess` 호출 → 통과
221
- 2. 만료 임박이거나 검증 실패 시 `refresh_token`으로 자동 갱신 시도
222
- 3. 갱신 성공 → 새 토큰 쿠키 설정 → `onAuthSuccess` 호출 → 통과
223
- 4. 갱신 실패 → 로그인 페이지로 redirect
284
+ 1. `lemon_access_token` 쿠키가 있고 만료까지 60초 이상 남았으면 → JWKS 검증 → `onAuthSuccess` 호출 → 통과
285
+ 2. 로그인은 됐지만 `clientId` 미승인이면 `unapprovedRedirectUrl`로 redirect
286
+ 3. 만료 임박이거나 검증 실패 시 `lemon_refresh_token` + `device_id`로 자동 갱신 시도
287
+ 4. 갱신 성공 → 새 토큰 쿠키 설정 → `onAuthSuccess` 호출 → 통과
288
+ 5. 갱신 실패 → `loginRedirectUrl`을 `redirect_url`로 담아 로그인 페이지로 redirect
289
+
290
+ `loginRedirectUrl`은 고정 URL 문자열과 요청별 resolver 함수를 모두 지원합니다.
291
+
292
+ ```ts
293
+ createAuthProxy({
294
+ // 로그인 후 항상 같은 화면으로 복귀
295
+ loginRedirectUrl: "https://myapp.lemondouble.com/dashboard",
296
+ });
297
+
298
+ createAuthProxy({
299
+ // 로그인 후 사용자가 원래 요청한 path/query로 복귀
300
+ loginRedirectUrl: (request) => request.url,
301
+ });
302
+ ```
224
303
 
225
304
  **공개 경로** (`publicPaths`에 해당하는 경로):
226
305
 
227
306
  1. 토큰이 만료 임박이면 백그라운드로 갱신 (실패해도 통과)
228
307
  2. 인증 여부와 무관하게 항상 통과
229
308
 
309
+ **API 경로** (`apiPaths` 매칭, 기본 `/api/*`):
310
+
311
+ 보호 경로 중 `apiPaths`에 매칭되는 요청은 인증 실패 시 redirect 대신 JSON 응답을 반환합니다. fetch 호출이 OAuth HTML로 리다이렉트되어 계약이 깨지는 문제를 방지합니다.
312
+
313
+ | 상황 | 응답 |
314
+ |------|------|
315
+ | 미인증 (access/refresh 모두 실패) | `401 { "code": "UNAUTHORIZED" }` |
316
+ | 미승인 (`clientId` 미포함) | `403 { "code": "FORBIDDEN" }` |
317
+
318
+ `PROXY_AUTH_ERROR` 상수와 `ProxyAuthErrorCode` 타입을 export합니다.
319
+
320
+ ```ts
321
+ import { PROXY_AUTH_ERROR } from "@lemondouble/lemon-auth/proxy";
322
+ import type { ProxyAuthErrorCode } from "@lemondouble/lemon-auth/proxy";
323
+
324
+ const res = await fetch("/api/users");
325
+ if (!res.ok) {
326
+ const data = (await res.json()) as { code: ProxyAuthErrorCode };
327
+ if (data.code === PROXY_AUTH_ERROR.UNAUTHORIZED) {
328
+ // 재로그인 유도
329
+ }
330
+ if (data.code === PROXY_AUTH_ERROR.FORBIDDEN) {
331
+ // "권한 없음" 표시
332
+ }
333
+ }
334
+ ```
335
+
336
+ `/api/*` 외의 경로(예: tRPC, GraphQL)도 JSON 응답을 받게 하려면 `apiPaths`에 추가합니다.
337
+
338
+ ```ts
339
+ import { DEFAULT_API_PATHS } from "@lemondouble/lemon-auth/proxy";
340
+
341
+ createAuthProxy({
342
+ apiPaths: [...DEFAULT_API_PATHS, "/trpc/*"],
343
+ });
344
+ ```
345
+
346
+ `apiPaths: []`로 두면 모든 보호 경로가 redirect로 동작합니다 (이전 버전 동작).
347
+
230
348
  ---
231
349
 
232
350
  ## Client — `@lemondouble/lemon-auth/client`
@@ -310,8 +428,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
310
428
  |------|------|-----------------|
311
429
  | 정상 요청 (proxy 실행됨) | 있음 | 아무것도 안 함 (skip) |
312
430
  | PWA 캐시 히트 + 갱신 시도 중 | 없음 | `fallback` 렌더 |
313
- | PWA 캐시 히트 + 유효한 refresh_token | 없음 | 갱신 → `router.refresh()` |
314
- | PWA 캐시 히트 + 만료된 refresh_token | 없음 | 갱신 실패 → `children` 렌더 |
431
+ | PWA 캐시 히트 + 유효한 `lemon_refresh_token` | 없음 | 갱신 → `router.refresh()` |
432
+ | PWA 캐시 히트 + 만료된 `lemon_refresh_token` | 없음 | 갱신 실패 → `children` 렌더 |
315
433
 
316
434
  ### `loginUrl(redirectUrl)`
317
435
 
@@ -326,11 +444,15 @@ loginUrl("https://myapp.lemondouble.com/dashboard")
326
444
 
327
445
  | 파라미터 | 타입 | 설명 |
328
446
  |---------|------|------|
329
- | `redirectUrl` | `string` | 로그인 완료 후 돌아올 URL (`https://` + `*.lemondouble.com`만 허용) |
447
+ | `redirectUrl` | `string` | 로그인 완료 후 돌아올 절대 URL (`https://` + `lemondouble.com` 또는 `*.lemondouble.com`만 허용) |
448
+
449
+ 허용되지 않는 URL이면 `Error`를 throw합니다. Mock User가 활성화된 로컬 개발 환경에서는 검증하지 않고 `redirectUrl`을 그대로 반환합니다.
330
450
 
331
451
  ### `logout()`
332
452
 
333
- 로그아웃을 수행합니다. refresh_token을 폐기하고 쿠키를 삭제합니다.
453
+ 로그아웃을 수행합니다. `lemon_refresh_token`을 폐기하고 쿠키를 삭제합니다.
454
+ 서버 응답이 성공이면 `true`, 실패 응답 또는 네트워크 예외면 `false`를 반환합니다.
455
+ 반환값을 무시하고 기존처럼 `await logout()`만 호출해도 됩니다.
334
456
 
335
457
  ```tsx
336
458
  "use client";
@@ -338,7 +460,10 @@ import { logout } from "@lemondouble/lemon-auth/client";
338
460
 
339
461
  function LogoutButton() {
340
462
  const handleLogout = async () => {
341
- await logout();
463
+ const ok = await logout();
464
+ if (!ok) {
465
+ // 필요하면 실패 안내를 표시
466
+ }
342
467
  window.location.href = "/";
343
468
  };
344
469
 
@@ -397,6 +522,7 @@ interface LemonUser {
397
522
  }
398
523
 
399
524
  interface AccessTokenClaims extends JWTPayload {
525
+ token_type: "access";
400
526
  sub: string;
401
527
  nickname: string;
402
528
  profile_image_url: string;
@@ -416,34 +542,52 @@ interface UserProfile {
416
542
 
417
543
  ## 전체 연동 예시
418
544
 
419
- 새 Next.js 16 프로젝트에 인증을 추가하는 최소 예시입니다.
545
+ 새 Next.js 16 프로젝트에 인증을 추가하는 권장 예시입니다.
420
546
 
421
547
  ### 1. proxy.ts — 토큰 자동 갱신
422
548
 
423
549
  ```ts
424
- import { createAuthProxy } from "@lemondouble/lemon-auth/proxy";
550
+ import {
551
+ createAuthProxy,
552
+ DEFAULT_API_PATHS,
553
+ } from "@lemondouble/lemon-auth/proxy";
425
554
 
426
555
  export default createAuthProxy({
427
- publicPaths: ["/", "/login"],
556
+ publicPaths: ["/", "/login", "/pending-approval", "/api/public/*"],
557
+ bypassPaths: ["/workbox-*"],
558
+ apiPaths: DEFAULT_API_PATHS,
428
559
  clientId: process.env.CLIENT_ID,
429
- loginRedirectUrl: process.env.NEXT_PUBLIC_BASE_URL,
560
+ loginRedirectUrl: (request) => request.url,
561
+ unapprovedRedirectUrl: "/pending-approval",
430
562
  });
431
563
 
432
564
  export const config = {
433
- matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
565
+ matcher: [
566
+ "/((?!_next/static|_next/image|sw.js|manifest.webmanifest|favicon.ico|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico|woff|woff2|ttf|eot|otf)$).*)",
567
+ ],
434
568
  };
435
569
  ```
436
570
 
571
+ `loginRedirectUrl: (request) => request.url`은 로그인 후 사용자가 원래 요청한 path/query로 돌아오게 합니다.
572
+ `apiPaths`에 매칭되는 보호 API는 인증 실패 시 redirect 대신 `401/403` JSON 응답을 반환합니다.
573
+ 기본 PWA 파일(`/sw.js`, `/manifest.webmanifest` 등)은 항상 bypass되며, 앱에서 추가로 생성하는 workbox 파일은 `bypassPaths`에 넣을 수 있습니다.
574
+
437
575
  `onAuthSuccess`를 사용하면 인증 성공 후 DB 동기화, 헤더 주입 등 앱별 로직을 끼워넣을 수 있습니다:
438
576
 
439
577
  ```ts
440
- import { createAuthProxy } from "@lemondouble/lemon-auth/proxy";
578
+ import {
579
+ createAuthProxy,
580
+ DEFAULT_API_PATHS,
581
+ } from "@lemondouble/lemon-auth/proxy";
441
582
  import { upsertUser } from "@/lib/db";
442
583
 
443
584
  export default createAuthProxy({
444
- publicPaths: ["/", "/login"],
585
+ publicPaths: ["/", "/login", "/pending-approval", "/api/public/*"],
586
+ bypassPaths: ["/workbox-*"],
587
+ apiPaths: DEFAULT_API_PATHS,
445
588
  clientId: process.env.CLIENT_ID,
446
- loginRedirectUrl: process.env.NEXT_PUBLIC_BASE_URL,
589
+ loginRedirectUrl: (request) => request.url,
590
+ unapprovedRedirectUrl: "/pending-approval",
447
591
  onAuthSuccess: async (claims, request, response) => {
448
592
  await upsertUser(claims);
449
593
  response.headers.set("x-user-uid", claims.sub);
@@ -517,7 +661,12 @@ import { logout } from "@lemondouble/lemon-auth/client";
517
661
 
518
662
  export function LogoutButton() {
519
663
  return (
520
- <button onClick={async () => { await logout(); window.location.href = "/"; }}>
664
+ <button
665
+ onClick={async () => {
666
+ await logout();
667
+ window.location.href = "/";
668
+ }}
669
+ >
521
670
  로그아웃
522
671
  </button>
523
672
  );
@@ -0,0 +1,16 @@
1
+ // src/claims.ts
2
+ function isAccessTokenClaims(payload) {
3
+ return payload.token_type === "access" && typeof payload.sub === "string" && typeof payload.nickname === "string" && typeof payload.profile_image_url === "string" && (payload.role === "user" || payload.role === "admin") && Array.isArray(payload.approved_clients) && payload.approved_clients.every((clientId) => typeof clientId === "string");
4
+ }
5
+
6
+ // src/approval.ts
7
+ function isApprovedClient(approvedClients, clientId) {
8
+ if (!clientId) return true;
9
+ return approvedClients.includes(clientId) || approvedClients.includes("*");
10
+ }
11
+
12
+ export {
13
+ isAccessTokenClaims,
14
+ isApprovedClient
15
+ };
16
+ //# sourceMappingURL=chunk-6W24RWSJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/claims.ts","../src/approval.ts"],"sourcesContent":["import type { JWTPayload } from \"jose\";\nimport type { AccessTokenClaims } from \"./types.js\";\n\nexport function isAccessTokenClaims(\n payload: JWTPayload\n): payload is AccessTokenClaims {\n return (\n payload.token_type === \"access\" &&\n typeof payload.sub === \"string\" &&\n typeof payload.nickname === \"string\" &&\n typeof payload.profile_image_url === \"string\" &&\n (payload.role === \"user\" || payload.role === \"admin\") &&\n Array.isArray(payload.approved_clients) &&\n payload.approved_clients.every((clientId) => typeof clientId === \"string\")\n );\n}\n","export function isApprovedClient(\n approvedClients: string[],\n clientId?: string\n): boolean {\n if (!clientId) return true;\n return approvedClients.includes(clientId) || approvedClients.includes(\"*\");\n}\n"],"mappings":";AAGO,SAAS,oBACd,SAC8B;AAC9B,SACE,QAAQ,eAAe,YACvB,OAAO,QAAQ,QAAQ,YACvB,OAAO,QAAQ,aAAa,YAC5B,OAAO,QAAQ,sBAAsB,aACpC,QAAQ,SAAS,UAAU,QAAQ,SAAS,YAC7C,MAAM,QAAQ,QAAQ,gBAAgB,KACtC,QAAQ,iBAAiB,MAAM,CAAC,aAAa,OAAO,aAAa,QAAQ;AAE7E;;;ACfO,SAAS,iBACd,iBACA,UACS;AACT,MAAI,CAAC,SAAU,QAAO;AACtB,SAAO,gBAAgB,SAAS,QAAQ,KAAK,gBAAgB,SAAS,GAAG;AAC3E;","names":[]}
@@ -0,0 +1,120 @@
1
+ // src/constants.ts
2
+ var AUTH_SERVER_URL = "https://auth.lemondouble.com";
3
+ var JWKS_URL = `${AUTH_SERVER_URL}/.well-known/jwks.json`;
4
+ var LOGIN_URL = `${AUTH_SERVER_URL}/api/oauth2/google/login`;
5
+ var REFRESH_URL = `${AUTH_SERVER_URL}/api/token/refresh`;
6
+ var LOGOUT_URL = `${AUTH_SERVER_URL}/api/token/logout`;
7
+ var PROFILE_URL = `${AUTH_SERVER_URL}/api/user/me`;
8
+ var PROFILE_PAGE_URL = `${AUTH_SERVER_URL}/profile`;
9
+ var ACCESS_TOKEN_COOKIE = "lemon_access_token";
10
+ var REFRESH_TOKEN_COOKIE = "lemon_refresh_token";
11
+ var DEVICE_ID_COOKIE = "device_id";
12
+
13
+ // src/mock.ts
14
+ var LEMON_AUTH_MOCK_USER_ENV = "NEXT_PUBLIC_LEMON_AUTH_MOCK_USER";
15
+ function isMockAuthEnabled() {
16
+ return !isProduction() && Boolean(readMockUserEnv());
17
+ }
18
+ function getMockUser() {
19
+ const raw = readMockUserEnv();
20
+ if (isProduction() || !raw) return null;
21
+ return parseMockUser(raw);
22
+ }
23
+ function getMockAccessTokenClaims() {
24
+ const user = getMockUser();
25
+ if (!user) return null;
26
+ const now = Math.floor(Date.now() / 1e3);
27
+ return {
28
+ token_type: "access",
29
+ sub: user.uid,
30
+ nickname: user.nickname,
31
+ profile_image_url: user.profileImageUrl,
32
+ role: user.role,
33
+ approved_clients: user.approvedClients,
34
+ iss: "lemon-auth-mock",
35
+ iat: now,
36
+ exp: now + 60 * 60 * 24 * 365
37
+ };
38
+ }
39
+ function readMockUserEnv() {
40
+ try {
41
+ return process.env.NEXT_PUBLIC_LEMON_AUTH_MOCK_USER;
42
+ } catch {
43
+ return void 0;
44
+ }
45
+ }
46
+ function isProduction() {
47
+ try {
48
+ return process.env.NODE_ENV === "production";
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+ function parseMockUser(raw) {
54
+ let data;
55
+ try {
56
+ data = JSON.parse(raw);
57
+ } catch {
58
+ throw new Error(
59
+ `${LEMON_AUTH_MOCK_USER_ENV} must be a valid JSON object`
60
+ );
61
+ }
62
+ if (!isRecord(data)) {
63
+ throw new Error(`${LEMON_AUTH_MOCK_USER_ENV} must be a JSON object`);
64
+ }
65
+ return {
66
+ uid: readOptionalString(data.uid, "local-user", "uid"),
67
+ nickname: readOptionalString(data.nickname, "Local User", "nickname"),
68
+ profileImageUrl: readOptionalString(
69
+ data.profileImageUrl ?? data.profile_image_url,
70
+ "",
71
+ "profileImageUrl"
72
+ ),
73
+ role: readOptionalRole(data.role, "admin"),
74
+ approvedClients: readOptionalStringArray(
75
+ data.approvedClients ?? data.approved_clients,
76
+ ["*"],
77
+ "approvedClients"
78
+ )
79
+ };
80
+ }
81
+ function isRecord(value) {
82
+ return typeof value === "object" && value !== null && !Array.isArray(value);
83
+ }
84
+ function readOptionalString(value, fallback, field) {
85
+ if (value === void 0 || value === null) return fallback;
86
+ if (typeof value === "string") return value;
87
+ throw new Error(`${LEMON_AUTH_MOCK_USER_ENV}.${field} must be a string`);
88
+ }
89
+ function readOptionalRole(value, fallback) {
90
+ if (value === void 0 || value === null) return fallback;
91
+ if (value === "user" || value === "admin") return value;
92
+ throw new Error(
93
+ `${LEMON_AUTH_MOCK_USER_ENV}.role must be "user" or "admin"`
94
+ );
95
+ }
96
+ function readOptionalStringArray(value, fallback, field) {
97
+ if (value === void 0 || value === null) return fallback;
98
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
99
+ return value;
100
+ }
101
+ throw new Error(
102
+ `${LEMON_AUTH_MOCK_USER_ENV}.${field} must be a string array`
103
+ );
104
+ }
105
+
106
+ export {
107
+ AUTH_SERVER_URL,
108
+ JWKS_URL,
109
+ LOGIN_URL,
110
+ REFRESH_URL,
111
+ LOGOUT_URL,
112
+ PROFILE_URL,
113
+ PROFILE_PAGE_URL,
114
+ ACCESS_TOKEN_COOKIE,
115
+ REFRESH_TOKEN_COOKIE,
116
+ DEVICE_ID_COOKIE,
117
+ isMockAuthEnabled,
118
+ getMockAccessTokenClaims
119
+ };
120
+ //# sourceMappingURL=chunk-OX5MDVSR.js.map
@@ -0,0 +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":"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","names":[]}
@@ -1,5 +1,5 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import { L as LemonUser } from '../types-CgrEUXwM.js';
2
+ import { a as LemonUser } from '../types-B5uRY2Si.js';
3
3
  import * as react from 'react';
4
4
  import 'jose';
5
5
 
@@ -20,7 +20,7 @@ declare function AutoTokenRefresh({ children, fallback, }: {
20
20
 
21
21
  declare function loginUrl(redirectUrl: string): string;
22
22
  declare function profileUrl(redirectUrl?: string): string;
23
- declare function logout(): Promise<void>;
23
+ declare function logout(): Promise<boolean>;
24
24
  declare function refreshToken(): Promise<boolean>;
25
25
 
26
26
  export { AuthProvider, AutoTokenRefresh, LemonUser, loginUrl, logout, profileUrl, refreshToken, useAuth };
@@ -1,10 +1,14 @@
1
1
  "use client";
2
2
  import {
3
+ ACCESS_TOKEN_COOKIE,
4
+ DEVICE_ID_COOKIE,
3
5
  LOGIN_URL,
4
6
  LOGOUT_URL,
5
7
  PROFILE_PAGE_URL,
6
- REFRESH_URL
7
- } from "../chunk-3ARMB6QF.js";
8
+ REFRESH_TOKEN_COOKIE,
9
+ REFRESH_URL,
10
+ isMockAuthEnabled
11
+ } from "../chunk-OX5MDVSR.js";
8
12
 
9
13
  // src/client/context.tsx
10
14
  import { createContext, useContext } from "react";
@@ -28,27 +32,101 @@ import { useEffect, useState } from "react";
28
32
  import { useRouter } from "next/navigation";
29
33
 
30
34
  // src/client/url.ts
35
+ var LOGOUT_COOKIE_NAMES = [
36
+ ACCESS_TOKEN_COOKIE,
37
+ REFRESH_TOKEN_COOKIE,
38
+ DEVICE_ID_COOKIE
39
+ ];
31
40
  function loginUrl(redirectUrl) {
41
+ if (isMockAuthEnabled()) return redirectUrl;
42
+ assertValidLoginRedirectUrl(redirectUrl);
32
43
  return `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;
33
44
  }
34
45
  function profileUrl(redirectUrl) {
46
+ if (isMockAuthEnabled()) return redirectUrl ?? "/";
35
47
  if (redirectUrl) {
36
48
  return `${PROFILE_PAGE_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;
37
49
  }
38
50
  return PROFILE_PAGE_URL;
39
51
  }
40
52
  async function logout() {
41
- await fetch(LOGOUT_URL, {
42
- method: "POST",
43
- credentials: "include"
44
- });
53
+ if (isMockAuthEnabled()) {
54
+ expireAuthCookies();
55
+ return true;
56
+ }
57
+ try {
58
+ const res = await fetch(LOGOUT_URL, {
59
+ method: "POST",
60
+ credentials: "include"
61
+ });
62
+ return res.ok;
63
+ } catch {
64
+ return false;
65
+ } finally {
66
+ expireAuthCookies();
67
+ }
45
68
  }
46
69
  async function refreshToken() {
47
- const res = await fetch(REFRESH_URL, {
48
- method: "POST",
49
- credentials: "include"
50
- });
51
- return res.ok;
70
+ if (isMockAuthEnabled()) return true;
71
+ try {
72
+ const res = await fetch(REFRESH_URL, {
73
+ method: "POST",
74
+ credentials: "include"
75
+ });
76
+ return res.ok;
77
+ } catch {
78
+ return false;
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
+ }
93
+ }
94
+ }
95
+ function getCookieDomains() {
96
+ if (typeof window === "undefined") return [""];
97
+ const { hostname } = window.location;
98
+ if (!hostname || hostname === "localhost" || isIpAddress(hostname)) {
99
+ return [""];
100
+ }
101
+ const labels = hostname.split(".");
102
+ const domains = [""];
103
+ for (let i = 0; i < labels.length - 1; i += 1) {
104
+ domains.push(`.${labels.slice(i).join(".")}`);
105
+ }
106
+ return domains;
107
+ }
108
+ function isIpAddress(hostname) {
109
+ return /^[\d.]+$/.test(hostname) || hostname.includes(":");
110
+ }
111
+ function assertValidLoginRedirectUrl(redirectUrl) {
112
+ let url;
113
+ try {
114
+ url = new URL(redirectUrl);
115
+ } catch {
116
+ throw new Error("loginUrl redirectUrl must be a valid absolute URL");
117
+ }
118
+ if (url.protocol !== "https:") {
119
+ throw new Error("loginUrl redirectUrl must use https");
120
+ }
121
+ if (!isAllowedLoginRedirectHost(url.hostname)) {
122
+ throw new Error(
123
+ "loginUrl redirectUrl host must be lemondouble.com or a subdomain of lemondouble.com"
124
+ );
125
+ }
126
+ }
127
+ function isAllowedLoginRedirectHost(hostname) {
128
+ const normalized = hostname.toLowerCase();
129
+ return normalized === "lemondouble.com" || normalized.endsWith(".lemondouble.com");
52
130
  }
53
131
 
54
132
  // src/client/auto-token-refresh.tsx