@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 +180 -31
- package/dist/chunk-6W24RWSJ.js +16 -0
- package/dist/chunk-6W24RWSJ.js.map +1 -0
- package/dist/chunk-OX5MDVSR.js +120 -0
- package/dist/chunk-OX5MDVSR.js.map +1 -0
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +89 -11
- package/dist/client/index.js.map +1 -1
- package/dist/proxy/index.d.ts +15 -4
- package/dist/proxy/index.js +157 -30
- package/dist/proxy/index.js.map +1 -1
- package/dist/server/index.d.ts +14 -5
- package/dist/server/index.js +53 -11
- package/dist/server/index.js.map +1 -1
- package/dist/{types-CgrEUXwM.d.ts → types-B5uRY2Si.d.ts} +11 -1
- package/package.json +9 -7
- package/dist/chunk-3ARMB6QF.js +0 -23
- package/dist/chunk-3ARMB6QF.js.map +0 -1
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
|
-
쿠키에서 `
|
|
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,
|
|
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
|
-
|
|
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
|
-
| `
|
|
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("
|
|
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, // "
|
|
180
|
-
REFRESH_TOKEN_COOKIE, // "
|
|
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:
|
|
252
|
+
loginRedirectUrl: (request) => request.url,
|
|
253
|
+
unapprovedRedirectUrl: "/pending-approval",
|
|
200
254
|
});
|
|
201
255
|
|
|
202
256
|
export const config = {
|
|
203
|
-
matcher: [
|
|
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. `
|
|
221
|
-
2.
|
|
222
|
-
3.
|
|
223
|
-
4. 갱신
|
|
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 캐시 히트 + 유효한
|
|
314
|
-
| PWA 캐시 히트 + 만료된
|
|
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
|
-
로그아웃을 수행합니다.
|
|
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 {
|
|
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:
|
|
560
|
+
loginRedirectUrl: (request) => request.url,
|
|
561
|
+
unapprovedRedirectUrl: "/pending-approval",
|
|
430
562
|
});
|
|
431
563
|
|
|
432
564
|
export const config = {
|
|
433
|
-
matcher: [
|
|
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 {
|
|
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:
|
|
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
|
|
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":[]}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
-
import {
|
|
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<
|
|
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 };
|
package/dist/client/index.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|