@lemondouble/lemon-auth 0.1.2 → 0.3.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 +455 -40
- package/dist/client/index.d.ts +6 -1
- package/dist/client/index.js +25 -0
- package/dist/client/index.js.map +1 -1
- package/dist/proxy/index.d.ts +4 -2
- package/dist/proxy/index.js +4 -3
- package/dist/proxy/index.js.map +1 -1
- package/package.json +8 -9
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
## 설치
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
|
|
10
|
+
pnpm add @lemondouble/lemon-auth
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
## 엔트리포인트
|
|
@@ -16,42 +16,187 @@ npm install @lemondouble/lemon-auth
|
|
|
16
16
|
|------|------|------|
|
|
17
17
|
| `@lemondouble/lemon-auth/server` | Server Components, Route Handlers, Server Actions | JWT 검증, 유저 조회, 인증 체크 |
|
|
18
18
|
| `@lemondouble/lemon-auth/client` | Client Components | AuthProvider, useAuth 훅, URL 헬퍼 |
|
|
19
|
-
| `@lemondouble/lemon-auth/proxy` | proxy.ts | 토큰 자동 갱신
|
|
19
|
+
| `@lemondouble/lemon-auth/proxy` | proxy.ts | 토큰 자동 갱신 프록시 |
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
---
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
## Server — `@lemondouble/lemon-auth/server`
|
|
24
|
+
|
|
25
|
+
Server Components, Route Handlers, Server Actions에서 사용합니다.
|
|
26
|
+
|
|
27
|
+
### `verifyAccessToken()`
|
|
28
|
+
|
|
29
|
+
쿠키에서 `access_token`을 읽어 JWKS(ES256)로 검증합니다.
|
|
24
30
|
|
|
25
31
|
```ts
|
|
26
|
-
import {
|
|
27
|
-
getUser,
|
|
28
|
-
requireAuth,
|
|
29
|
-
requireClient,
|
|
30
|
-
verifyAccessToken,
|
|
31
|
-
} from "@lemondouble/lemon-auth/server";
|
|
32
|
+
import { verifyAccessToken } from "@lemondouble/lemon-auth/server";
|
|
32
33
|
|
|
33
|
-
// JWT 검증 (쿠키에서 자동으로 읽음)
|
|
34
34
|
const claims = await verifyAccessToken();
|
|
35
|
+
// → AccessTokenClaims | null
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
반환되는 `AccessTokenClaims`:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
interface AccessTokenClaims {
|
|
42
|
+
sub: string; // 사용자 UUID
|
|
43
|
+
nickname: string; // 닉네임
|
|
44
|
+
profile_image_url: string; // 프로필 이미지 URL
|
|
45
|
+
role: "user" | "admin"; // 역할
|
|
46
|
+
approved_clients: string[]; // 승인된 클라이언트 ID 목록
|
|
47
|
+
iss: string; // issuer (https://auth.lemondouble.com)
|
|
48
|
+
exp: number; // 만료 시간
|
|
49
|
+
iat: number; // 발급 시간
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### `verifyAccessTokenString(token)`
|
|
54
|
+
|
|
55
|
+
토큰 문자열을 직접 전달하여 검증합니다. 쿠키 대신 `Authorization` 헤더 등에서 토큰을 꺼낼 때 사용합니다.
|
|
35
56
|
|
|
36
|
-
|
|
37
|
-
|
|
57
|
+
```ts
|
|
58
|
+
import { verifyAccessTokenString } from "@lemondouble/lemon-auth/server";
|
|
59
|
+
|
|
60
|
+
// Route Handler에서 Authorization 헤더 사용 예시
|
|
61
|
+
export async function GET(request: Request) {
|
|
62
|
+
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
|
|
63
|
+
if (!token) return new Response(null, { status: 401 });
|
|
64
|
+
|
|
65
|
+
const claims = await verifyAccessTokenString(token);
|
|
66
|
+
if (!claims) return new Response(null, { status: 401 });
|
|
67
|
+
|
|
68
|
+
return Response.json({ userId: claims.sub });
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### `getUser()`
|
|
38
73
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const user = await requireAuth("/login"); // "/login" 으로 redirect
|
|
74
|
+
`verifyAccessToken()`을 호출하고 결과를 `LemonUser` 객체로 변환합니다.
|
|
75
|
+
React `cache()`로 감싸져 있어서 **같은 요청 내에서 여러 번 호출해도 JWT 검증은 1회만** 실행됩니다.
|
|
42
76
|
|
|
43
|
-
|
|
44
|
-
|
|
77
|
+
```ts
|
|
78
|
+
import { getUser } from "@lemondouble/lemon-auth/server";
|
|
79
|
+
|
|
80
|
+
export default async function Page() {
|
|
81
|
+
const user = await getUser();
|
|
82
|
+
// → LemonUser | null
|
|
83
|
+
|
|
84
|
+
if (!user) return <p>로그인이 필요합니다</p>;
|
|
85
|
+
return <p>{user.nickname}님 환영합니다</p>;
|
|
86
|
+
}
|
|
45
87
|
```
|
|
46
88
|
|
|
47
|
-
|
|
89
|
+
반환되는 `LemonUser`:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
interface LemonUser {
|
|
93
|
+
uid: string; // 사용자 UUID
|
|
94
|
+
nickname: string; // 닉네임
|
|
95
|
+
profileImageUrl: string; // 프로필 이미지 URL
|
|
96
|
+
role: "user" | "admin"; // 역할
|
|
97
|
+
approvedClients: string[]; // 승인된 클라이언트 ID 목록
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### `requireAuth(redirectTo?)`
|
|
102
|
+
|
|
103
|
+
인증된 유저를 반환합니다. 미인증이면 `redirectTo`로 redirect합니다.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { requireAuth } from "@lemondouble/lemon-auth/server";
|
|
107
|
+
|
|
108
|
+
export default async function ProtectedPage() {
|
|
109
|
+
const user = await requireAuth(); // 미인증 시 "/" 로 redirect
|
|
110
|
+
// const user = await requireAuth("/login"); // 미인증 시 "/login" 으로 redirect
|
|
111
|
+
|
|
112
|
+
return <p>{user.nickname}님의 대시보드</p>;
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| 파라미터 | 타입 | 기본값 | 설명 |
|
|
117
|
+
|---------|------|--------|------|
|
|
118
|
+
| `redirectTo` | `string` | `"/"` | 미인증 시 redirect 경로 |
|
|
119
|
+
|
|
120
|
+
### `requireClient(clientId, redirectTo?)`
|
|
121
|
+
|
|
122
|
+
`requireAuth()` + `approved_clients` 체크. 관리자가 승인한 사용자만 접근할 수 있는 서비스에서 사용합니다.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { requireClient } from "@lemondouble/lemon-auth/server";
|
|
126
|
+
|
|
127
|
+
const CLIENT_ID = process.env.CLIENT_ID!;
|
|
128
|
+
|
|
129
|
+
export default async function Page() {
|
|
130
|
+
const user = await requireClient(CLIENT_ID);
|
|
131
|
+
// approved_clients에 CLIENT_ID가 없으면 Error("Client not approved") throw
|
|
132
|
+
return <p>{user.nickname}</p>;
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
| 파라미터 | 타입 | 기본값 | 설명 |
|
|
137
|
+
|---------|------|--------|------|
|
|
138
|
+
| `clientId` | `string` | (필수) | 체크할 클라이언트 UUID |
|
|
139
|
+
| `redirectTo` | `string` | `"/"` | 미인증 시 redirect 경로 |
|
|
140
|
+
|
|
141
|
+
### `refreshTokenFromCookie(refreshTokenCookie)`
|
|
142
|
+
|
|
143
|
+
서버사이드에서 토큰 갱신을 수행합니다. Route Handler 등에서 수동으로 갱신할 때 사용합니다.
|
|
48
144
|
|
|
49
145
|
```ts
|
|
146
|
+
import { refreshTokenFromCookie } from "@lemondouble/lemon-auth/server";
|
|
147
|
+
|
|
148
|
+
export async function POST(request: Request) {
|
|
149
|
+
const cookieStore = await cookies();
|
|
150
|
+
const refreshToken = cookieStore.get("refresh_token")?.value;
|
|
151
|
+
if (!refreshToken) return new Response(null, { status: 401 });
|
|
152
|
+
|
|
153
|
+
const result = await refreshTokenFromCookie(refreshToken);
|
|
154
|
+
// result: { ok: boolean, setCookieHeaders: string[] }
|
|
155
|
+
|
|
156
|
+
if (!result.ok) return new Response(null, { status: 401 });
|
|
157
|
+
|
|
158
|
+
const response = new Response(null, { status: 200 });
|
|
159
|
+
for (const header of result.setCookieHeaders) {
|
|
160
|
+
response.headers.append("Set-Cookie", header);
|
|
161
|
+
}
|
|
162
|
+
return response;
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### 상수
|
|
167
|
+
|
|
168
|
+
서버 엔트리포인트에서 모든 상수를 re-export합니다.
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import {
|
|
172
|
+
AUTH_SERVER_URL, // "https://auth.lemondouble.com"
|
|
173
|
+
JWKS_URL, // "https://auth.lemondouble.com/.well-known/jwks.json"
|
|
174
|
+
LOGIN_URL, // "https://auth.lemondouble.com/api/oauth2/google/login"
|
|
175
|
+
REFRESH_URL, // "https://auth.lemondouble.com/api/token/refresh"
|
|
176
|
+
LOGOUT_URL, // "https://auth.lemondouble.com/api/token/logout"
|
|
177
|
+
PROFILE_URL, // "https://auth.lemondouble.com/api/user/me"
|
|
178
|
+
PROFILE_PAGE_URL, // "https://auth.lemondouble.com/profile"
|
|
179
|
+
ACCESS_TOKEN_COOKIE, // "access_token"
|
|
180
|
+
REFRESH_TOKEN_COOKIE, // "refresh_token"
|
|
181
|
+
} from "@lemondouble/lemon-auth/server";
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Proxy — `@lemondouble/lemon-auth/proxy`
|
|
187
|
+
|
|
188
|
+
Next.js 16의 `proxy.ts`에서 사용합니다. 보호 경로 접근 시 JWT 검증 + 만료 임박 토큰 자동 갱신을 처리합니다.
|
|
189
|
+
|
|
190
|
+
### `createAuthProxy(options?)`
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
// proxy.ts (프로젝트 루트)
|
|
50
194
|
import { createAuthProxy } from "@lemondouble/lemon-auth/proxy";
|
|
51
195
|
|
|
52
196
|
export default createAuthProxy({
|
|
53
|
-
publicPaths: ["/", "/about"],
|
|
54
|
-
clientId: process.env.CLIENT_ID,
|
|
197
|
+
publicPaths: ["/", "/about", "/api/public/*"],
|
|
198
|
+
clientId: process.env.CLIENT_ID,
|
|
199
|
+
loginRedirectUrl: "https://myapp.lemondouble.com",
|
|
55
200
|
});
|
|
56
201
|
|
|
57
202
|
export const config = {
|
|
@@ -59,46 +204,189 @@ export const config = {
|
|
|
59
204
|
};
|
|
60
205
|
```
|
|
61
206
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
207
|
+
#### `AuthProxyOptions`
|
|
208
|
+
|
|
209
|
+
| 옵션 | 타입 | 기본값 | 설명 |
|
|
210
|
+
|------|------|--------|------|
|
|
211
|
+
| `publicPaths` | `string[]` | `[]` | 인증 없이 접근 가능한 경로. `*`로 끝나면 prefix 매칭 (예: `"/api/public/*"`) |
|
|
212
|
+
| `clientId` | `string` | `undefined` | 설정 시 `approved_clients`에 포함 여부를 체크 |
|
|
213
|
+
| `loginRedirectUrl` | `string` | `undefined` | 미인증 시 Google 로그인 후 돌아올 URL. 미설정 시 `"/"` 로 redirect |
|
|
214
|
+
| `onAuthSuccess` | `(claims, request, response) => Promise<NextResponse> \| NextResponse` | `undefined` | 보호 경로에서 인증 성공 시 호출되는 콜백. DB 동기화, 헤더 주입 등 앱별 로직에 사용 |
|
|
215
|
+
|
|
216
|
+
#### 동작 방식
|
|
217
|
+
|
|
218
|
+
**보호 경로** (`publicPaths`에 해당하지 않는 경로):
|
|
219
|
+
|
|
220
|
+
1. `access_token` 쿠키가 있고 만료까지 60초 이상 남았으면 → JWKS 검증 → `onAuthSuccess` 호출 → 통과
|
|
221
|
+
2. 만료 임박이거나 검증 실패 시 `refresh_token`으로 자동 갱신 시도
|
|
222
|
+
3. 갱신 성공 → 새 토큰 쿠키 설정 → `onAuthSuccess` 호출 → 통과
|
|
223
|
+
4. 갱신 실패 → 로그인 페이지로 redirect
|
|
224
|
+
|
|
225
|
+
**공개 경로** (`publicPaths`에 해당하는 경로):
|
|
226
|
+
|
|
227
|
+
1. 토큰이 만료 임박이면 백그라운드로 갱신 (실패해도 통과)
|
|
228
|
+
2. 인증 여부와 무관하게 항상 통과
|
|
66
229
|
|
|
67
|
-
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Client — `@lemondouble/lemon-auth/client`
|
|
233
|
+
|
|
234
|
+
Client Components에서 사용합니다.
|
|
235
|
+
|
|
236
|
+
### `<AuthProvider>` + `useAuth()`
|
|
237
|
+
|
|
238
|
+
Server Component에서 유저 정보를 받아 Client Component 트리에 전달합니다.
|
|
68
239
|
|
|
69
240
|
```tsx
|
|
70
|
-
// layout.tsx (Server Component)
|
|
241
|
+
// app/layout.tsx (Server Component)
|
|
71
242
|
import { getUser } from "@lemondouble/lemon-auth/server";
|
|
72
243
|
import { AuthProvider } from "@lemondouble/lemon-auth/client";
|
|
73
244
|
|
|
74
|
-
export default async function
|
|
245
|
+
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
75
246
|
const user = await getUser();
|
|
76
|
-
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<html lang="ko">
|
|
250
|
+
<body>
|
|
251
|
+
<AuthProvider user={user}>
|
|
252
|
+
{children}
|
|
253
|
+
</AuthProvider>
|
|
254
|
+
</body>
|
|
255
|
+
</html>
|
|
256
|
+
);
|
|
77
257
|
}
|
|
78
258
|
```
|
|
79
259
|
|
|
80
260
|
```tsx
|
|
81
|
-
//
|
|
261
|
+
// components/nav.tsx (Client Component)
|
|
82
262
|
"use client";
|
|
83
|
-
import { useAuth
|
|
263
|
+
import { useAuth } from "@lemondouble/lemon-auth/client";
|
|
84
264
|
|
|
85
|
-
function
|
|
265
|
+
export function Nav() {
|
|
86
266
|
const { user, isAuthenticated } = useAuth();
|
|
267
|
+
// user: LemonUser | null
|
|
268
|
+
// isAuthenticated: boolean
|
|
87
269
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
270
|
+
if (!isAuthenticated) return <a href="/login">로그인</a>;
|
|
271
|
+
return <span>{user!.nickname}</span>;
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### `<AutoTokenRefresh>`
|
|
276
|
+
|
|
277
|
+
PWA 환경에서 service worker 캐시로 인해 proxy가 실행되지 않는 경우를 대비합니다.
|
|
278
|
+
`AuthProvider`의 `user`가 null일 때만 토큰 갱신을 시도하고, 성공하면 `router.refresh()`로 서버 컴포넌트를 다시 렌더합니다.
|
|
279
|
+
|
|
280
|
+
```tsx
|
|
281
|
+
// app/layout.tsx
|
|
282
|
+
import { getUser } from "@lemondouble/lemon-auth/server";
|
|
283
|
+
import { AuthProvider, AutoTokenRefresh } from "@lemondouble/lemon-auth/client";
|
|
284
|
+
|
|
285
|
+
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
286
|
+
const user = await getUser();
|
|
287
|
+
return (
|
|
288
|
+
<html lang="ko">
|
|
289
|
+
<body>
|
|
290
|
+
<AuthProvider user={user}>
|
|
291
|
+
<AutoTokenRefresh />
|
|
292
|
+
{children}
|
|
293
|
+
</AuthProvider>
|
|
294
|
+
</body>
|
|
295
|
+
</html>
|
|
96
296
|
);
|
|
97
297
|
}
|
|
98
298
|
```
|
|
99
299
|
|
|
300
|
+
`children`을 전달하면 갱신 실패 시 (미인증 상태일 때) fallback UI를 렌더합니다:
|
|
301
|
+
|
|
302
|
+
```tsx
|
|
303
|
+
<AutoTokenRefresh>
|
|
304
|
+
<p>로그인 정보를 확인하는 중...</p>
|
|
305
|
+
</AutoTokenRefresh>
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**동작 방식:**
|
|
309
|
+
|
|
310
|
+
| 상황 | user | AutoTokenRefresh |
|
|
311
|
+
|------|------|-----------------|
|
|
312
|
+
| 정상 요청 (proxy 실행됨) | 있음 | 아무것도 안 함 (skip) |
|
|
313
|
+
| PWA 캐시 히트 + 유효한 refresh_token | 없음 | 갱신 → `router.refresh()` |
|
|
314
|
+
| PWA 캐시 히트 + 만료된 refresh_token | 없음 | 갱신 실패 → children 렌더 |
|
|
315
|
+
|
|
316
|
+
### `loginUrl(redirectUrl)`
|
|
317
|
+
|
|
318
|
+
Google 로그인 URL을 생성합니다. `<a>` 태그나 `window.location.href`에 사용합니다.
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
import { loginUrl } from "@lemondouble/lemon-auth/client";
|
|
322
|
+
|
|
323
|
+
loginUrl("https://myapp.lemondouble.com/dashboard")
|
|
324
|
+
// → "https://auth.lemondouble.com/api/oauth2/google/login?redirect_url=https%3A%2F%2Fmyapp.lemondouble.com%2Fdashboard"
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
| 파라미터 | 타입 | 설명 |
|
|
328
|
+
|---------|------|------|
|
|
329
|
+
| `redirectUrl` | `string` | 로그인 완료 후 돌아올 URL (`https://` + `*.lemondouble.com`만 허용) |
|
|
330
|
+
|
|
331
|
+
### `logout()`
|
|
332
|
+
|
|
333
|
+
로그아웃을 수행합니다. refresh_token을 폐기하고 쿠키를 삭제합니다.
|
|
334
|
+
|
|
335
|
+
```tsx
|
|
336
|
+
"use client";
|
|
337
|
+
import { logout } from "@lemondouble/lemon-auth/client";
|
|
338
|
+
|
|
339
|
+
function LogoutButton() {
|
|
340
|
+
const handleLogout = async () => {
|
|
341
|
+
await logout();
|
|
342
|
+
window.location.href = "/";
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
return <button onClick={handleLogout}>로그아웃</button>;
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### `profileUrl(redirectUrl?)`
|
|
350
|
+
|
|
351
|
+
프로필 편집 페이지 URL을 생성합니다. 닉네임/프로필 이미지 변경 페이지로 이동시킬 때 사용합니다.
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
import { profileUrl } from "@lemondouble/lemon-auth/client";
|
|
355
|
+
|
|
356
|
+
profileUrl()
|
|
357
|
+
// → "https://auth.lemondouble.com/profile"
|
|
358
|
+
|
|
359
|
+
profileUrl("https://myapp.lemondouble.com/settings")
|
|
360
|
+
// → "https://auth.lemondouble.com/profile?redirect_url=https%3A%2F%2Fmyapp.lemondouble.com%2Fsettings"
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
| 파라미터 | 타입 | 기본값 | 설명 |
|
|
364
|
+
|---------|------|--------|------|
|
|
365
|
+
| `redirectUrl` | `string` | `undefined` | 편집 완료 후 돌아올 URL. 설정하면 "← 돌아가기" 버튼이 표시됨 |
|
|
366
|
+
|
|
367
|
+
### `refreshToken()`
|
|
368
|
+
|
|
369
|
+
클라이언트에서 토큰 갱신을 수행합니다. 보통 proxy에서 자동으로 처리하므로 직접 호출할 일은 적습니다.
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
import { refreshToken } from "@lemondouble/lemon-auth/client";
|
|
373
|
+
|
|
374
|
+
const ok = await refreshToken();
|
|
375
|
+
// → boolean (갱신 성공 여부)
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
100
380
|
## 타입
|
|
101
381
|
|
|
382
|
+
세 엔트리포인트 모두에서 타입을 import할 수 있습니다.
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import type { LemonUser, AccessTokenClaims } from "@lemondouble/lemon-auth/server";
|
|
386
|
+
import type { LemonUser } from "@lemondouble/lemon-auth/client";
|
|
387
|
+
import type { LemonUser, AccessTokenClaims } from "@lemondouble/lemon-auth/proxy";
|
|
388
|
+
```
|
|
389
|
+
|
|
102
390
|
```ts
|
|
103
391
|
interface LemonUser {
|
|
104
392
|
uid: string;
|
|
@@ -107,4 +395,131 @@ interface LemonUser {
|
|
|
107
395
|
role: "user" | "admin";
|
|
108
396
|
approvedClients: string[];
|
|
109
397
|
}
|
|
398
|
+
|
|
399
|
+
interface AccessTokenClaims extends JWTPayload {
|
|
400
|
+
sub: string;
|
|
401
|
+
nickname: string;
|
|
402
|
+
profile_image_url: string;
|
|
403
|
+
role: "user" | "admin";
|
|
404
|
+
approved_clients: string[];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
interface UserProfile {
|
|
408
|
+
uid: string;
|
|
409
|
+
nickname: string;
|
|
410
|
+
profile_image_url: string;
|
|
411
|
+
role: "user" | "admin";
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## 전체 연동 예시
|
|
418
|
+
|
|
419
|
+
새 Next.js 16 프로젝트에 인증을 추가하는 최소 예시입니다.
|
|
420
|
+
|
|
421
|
+
### 1. proxy.ts — 토큰 자동 갱신
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
import { createAuthProxy } from "@lemondouble/lemon-auth/proxy";
|
|
425
|
+
|
|
426
|
+
export default createAuthProxy({
|
|
427
|
+
publicPaths: ["/", "/login"],
|
|
428
|
+
clientId: process.env.CLIENT_ID,
|
|
429
|
+
loginRedirectUrl: process.env.NEXT_PUBLIC_BASE_URL,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
export const config = {
|
|
433
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
434
|
+
};
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
`onAuthSuccess`를 사용하면 인증 성공 후 DB 동기화, 헤더 주입 등 앱별 로직을 끼워넣을 수 있습니다:
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
import { createAuthProxy } from "@lemondouble/lemon-auth/proxy";
|
|
441
|
+
import { upsertUser } from "@/lib/db";
|
|
442
|
+
|
|
443
|
+
export default createAuthProxy({
|
|
444
|
+
publicPaths: ["/", "/login"],
|
|
445
|
+
clientId: process.env.CLIENT_ID,
|
|
446
|
+
loginRedirectUrl: process.env.NEXT_PUBLIC_BASE_URL,
|
|
447
|
+
onAuthSuccess: async (claims, request, response) => {
|
|
448
|
+
await upsertUser(claims);
|
|
449
|
+
response.headers.set("x-user-uid", claims.sub);
|
|
450
|
+
response.headers.set("x-user-nickname", encodeURIComponent(claims.nickname));
|
|
451
|
+
return response;
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
### 2. app/layout.tsx — AuthProvider + AutoTokenRefresh
|
|
457
|
+
|
|
458
|
+
```tsx
|
|
459
|
+
import { getUser } from "@lemondouble/lemon-auth/server";
|
|
460
|
+
import { AuthProvider, AutoTokenRefresh } from "@lemondouble/lemon-auth/client";
|
|
461
|
+
|
|
462
|
+
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
463
|
+
const user = await getUser();
|
|
464
|
+
return (
|
|
465
|
+
<html lang="ko">
|
|
466
|
+
<body>
|
|
467
|
+
<AuthProvider user={user}>
|
|
468
|
+
<AutoTokenRefresh />
|
|
469
|
+
{children}
|
|
470
|
+
</AuthProvider>
|
|
471
|
+
</body>
|
|
472
|
+
</html>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### 3. app/page.tsx — 공개 페이지 (로그인 버튼)
|
|
478
|
+
|
|
479
|
+
```tsx
|
|
480
|
+
import { getUser } from "@lemondouble/lemon-auth/server";
|
|
481
|
+
import { redirect } from "next/navigation";
|
|
482
|
+
import { LoginButton } from "@/components/login-button";
|
|
483
|
+
|
|
484
|
+
export default async function Home() {
|
|
485
|
+
const user = await getUser();
|
|
486
|
+
if (user) redirect("/dashboard");
|
|
487
|
+
return <LoginButton />;
|
|
488
|
+
}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
```tsx
|
|
492
|
+
// components/login-button.tsx
|
|
493
|
+
"use client";
|
|
494
|
+
import { loginUrl } from "@lemondouble/lemon-auth/client";
|
|
495
|
+
|
|
496
|
+
export function LoginButton() {
|
|
497
|
+
return <a href={loginUrl("https://myapp.lemondouble.com/dashboard")}>Google로 로그인</a>;
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
### 4. app/dashboard/page.tsx — 보호 페이지
|
|
502
|
+
|
|
503
|
+
```tsx
|
|
504
|
+
import { requireAuth } from "@lemondouble/lemon-auth/server";
|
|
505
|
+
|
|
506
|
+
export default async function Dashboard() {
|
|
507
|
+
const user = await requireAuth();
|
|
508
|
+
return <h1>{user.nickname}님의 대시보드</h1>;
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### 5. 로그아웃
|
|
513
|
+
|
|
514
|
+
```tsx
|
|
515
|
+
"use client";
|
|
516
|
+
import { logout } from "@lemondouble/lemon-auth/client";
|
|
517
|
+
|
|
518
|
+
export function LogoutButton() {
|
|
519
|
+
return (
|
|
520
|
+
<button onClick={async () => { await logout(); window.location.href = "/"; }}>
|
|
521
|
+
로그아웃
|
|
522
|
+
</button>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
110
525
|
```
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
2
|
import { L as LemonUser } from '../types-CgrEUXwM.js';
|
|
3
|
+
import * as react from 'react';
|
|
3
4
|
import 'jose';
|
|
4
5
|
|
|
5
6
|
interface AuthContextValue {
|
|
@@ -12,9 +13,13 @@ declare function AuthProvider({ user, children, }: {
|
|
|
12
13
|
}): react_jsx_runtime.JSX.Element;
|
|
13
14
|
declare function useAuth(): AuthContextValue;
|
|
14
15
|
|
|
16
|
+
declare function AutoTokenRefresh({ children }: {
|
|
17
|
+
children?: React.ReactNode;
|
|
18
|
+
}): string | number | bigint | boolean | react.ReactElement<unknown, string | react.JSXElementConstructor<any>> | Iterable<react.ReactNode> | Promise<string | number | bigint | boolean | react.ReactPortal | react.ReactElement<unknown, string | react.JSXElementConstructor<any>> | Iterable<react.ReactNode> | null | undefined> | null;
|
|
19
|
+
|
|
15
20
|
declare function loginUrl(redirectUrl: string): string;
|
|
16
21
|
declare function profileUrl(redirectUrl?: string): string;
|
|
17
22
|
declare function logout(): Promise<void>;
|
|
18
23
|
declare function refreshToken(): Promise<boolean>;
|
|
19
24
|
|
|
20
|
-
export { AuthProvider, LemonUser, loginUrl, logout, profileUrl, refreshToken, useAuth };
|
|
25
|
+
export { AuthProvider, AutoTokenRefresh, LemonUser, loginUrl, logout, profileUrl, refreshToken, useAuth };
|
package/dist/client/index.js
CHANGED
|
@@ -22,6 +22,10 @@ function useAuth() {
|
|
|
22
22
|
return useContext(AuthContext);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// src/client/auto-token-refresh.tsx
|
|
26
|
+
import { useEffect } from "react";
|
|
27
|
+
import { useRouter } from "next/navigation";
|
|
28
|
+
|
|
25
29
|
// src/client/url.ts
|
|
26
30
|
function loginUrl(redirectUrl) {
|
|
27
31
|
return `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;
|
|
@@ -45,8 +49,29 @@ async function refreshToken() {
|
|
|
45
49
|
});
|
|
46
50
|
return res.ok;
|
|
47
51
|
}
|
|
52
|
+
|
|
53
|
+
// src/client/auto-token-refresh.tsx
|
|
54
|
+
function AutoTokenRefresh({ children }) {
|
|
55
|
+
const { isAuthenticated } = useAuth();
|
|
56
|
+
const router = useRouter();
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (isAuthenticated) return;
|
|
59
|
+
let ignore = false;
|
|
60
|
+
refreshToken().then((ok) => {
|
|
61
|
+
if (!ignore && ok) {
|
|
62
|
+
router.refresh();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return () => {
|
|
66
|
+
ignore = true;
|
|
67
|
+
};
|
|
68
|
+
}, [isAuthenticated, router]);
|
|
69
|
+
if (isAuthenticated) return null;
|
|
70
|
+
return children ?? null;
|
|
71
|
+
}
|
|
48
72
|
export {
|
|
49
73
|
AuthProvider,
|
|
74
|
+
AutoTokenRefresh,
|
|
50
75
|
loginUrl,
|
|
51
76
|
logout,
|
|
52
77
|
profileUrl,
|
package/dist/client/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/client/context.tsx","../../src/client/url.ts"],"sourcesContent":["\"use client\";\n\nimport { createContext, useContext } from \"react\";\nimport type { LemonUser } from \"../types.js\";\n\ninterface AuthContextValue {\n user: LemonUser | null;\n isAuthenticated: boolean;\n}\n\nconst AuthContext = createContext<AuthContextValue>({\n user: null,\n isAuthenticated: false,\n});\n\nexport function AuthProvider({\n user,\n children,\n}: {\n user: LemonUser | null;\n children: React.ReactNode;\n}) {\n return (\n <AuthContext value={{ user, isAuthenticated: user !== null }}>\n {children}\n </AuthContext>\n );\n}\n\nexport function useAuth(): AuthContextValue {\n return useContext(AuthContext);\n}\n","import {\n LOGIN_URL,\n LOGOUT_URL,\n PROFILE_PAGE_URL,\n REFRESH_URL,\n} from \"../constants.js\";\n\nexport function loginUrl(redirectUrl: string): string {\n return `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n}\n\nexport function profileUrl(redirectUrl?: string): string {\n if (redirectUrl) {\n return `${PROFILE_PAGE_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n }\n return PROFILE_PAGE_URL;\n}\n\nexport async function logout(): Promise<void> {\n await fetch(LOGOUT_URL, {\n method: \"POST\",\n credentials: \"include\",\n });\n}\n\nexport async function refreshToken(): Promise<boolean> {\n const res = await fetch(REFRESH_URL, {\n method: \"POST\",\n credentials: \"include\",\n });\n return res.ok;\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;;;
|
|
1
|
+
{"version":3,"sources":["../../src/client/context.tsx","../../src/client/auto-token-refresh.tsx","../../src/client/url.ts"],"sourcesContent":["\"use client\";\n\nimport { createContext, useContext } from \"react\";\nimport type { LemonUser } from \"../types.js\";\n\ninterface AuthContextValue {\n user: LemonUser | null;\n isAuthenticated: boolean;\n}\n\nconst AuthContext = createContext<AuthContextValue>({\n user: null,\n isAuthenticated: false,\n});\n\nexport function AuthProvider({\n user,\n children,\n}: {\n user: LemonUser | null;\n children: React.ReactNode;\n}) {\n return (\n <AuthContext value={{ user, isAuthenticated: user !== null }}>\n {children}\n </AuthContext>\n );\n}\n\nexport function useAuth(): AuthContextValue {\n return useContext(AuthContext);\n}\n","\"use client\";\n\nimport { useEffect } from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport { useAuth } from \"./context.js\";\nimport { refreshToken } from \"./url.js\";\n\nexport function AutoTokenRefresh({ children }: { children?: React.ReactNode }) {\n const { isAuthenticated } = useAuth();\n const router = useRouter();\n\n useEffect(() => {\n if (isAuthenticated) return;\n\n let ignore = false;\n\n refreshToken().then((ok) => {\n if (!ignore && ok) {\n router.refresh();\n }\n });\n\n return () => {\n ignore = true;\n };\n }, [isAuthenticated, router]);\n\n if (isAuthenticated) return null;\n return children ?? null;\n}\n","import {\n LOGIN_URL,\n LOGOUT_URL,\n PROFILE_PAGE_URL,\n REFRESH_URL,\n} from \"../constants.js\";\n\nexport function loginUrl(redirectUrl: string): string {\n return `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n}\n\nexport function profileUrl(redirectUrl?: string): string {\n if (redirectUrl) {\n return `${PROFILE_PAGE_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n }\n return PROFILE_PAGE_URL;\n}\n\nexport async function logout(): Promise<void> {\n await fetch(LOGOUT_URL, {\n method: \"POST\",\n credentials: \"include\",\n });\n}\n\nexport async function refreshToken(): Promise<boolean> {\n const res = await fetch(REFRESH_URL, {\n method: \"POST\",\n credentials: \"include\",\n });\n return res.ok;\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,iBAAiB;AAC1B,SAAS,iBAAiB;;;ACInB,SAAS,SAAS,aAA6B;AACpD,SAAO,GAAG,SAAS,iBAAiB,mBAAmB,WAAW,CAAC;AACrE;AAEO,SAAS,WAAW,aAA8B;AACvD,MAAI,aAAa;AACf,WAAO,GAAG,gBAAgB,iBAAiB,mBAAmB,WAAW,CAAC;AAAA,EAC5E;AACA,SAAO;AACT;AAEA,eAAsB,SAAwB;AAC5C,QAAM,MAAM,YAAY;AAAA,IACtB,QAAQ;AAAA,IACR,aAAa;AAAA,EACf,CAAC;AACH;AAEA,eAAsB,eAAiC;AACrD,QAAM,MAAM,MAAM,MAAM,aAAa;AAAA,IACnC,QAAQ;AAAA,IACR,aAAa;AAAA,EACf,CAAC;AACD,SAAO,IAAI;AACb;;;ADxBO,SAAS,iBAAiB,EAAE,SAAS,GAAmC;AAC7E,QAAM,EAAE,gBAAgB,IAAI,QAAQ;AACpC,QAAM,SAAS,UAAU;AAEzB,YAAU,MAAM;AACd,QAAI,gBAAiB;AAErB,QAAI,SAAS;AAEb,iBAAa,EAAE,KAAK,CAAC,OAAO;AAC1B,UAAI,CAAC,UAAU,IAAI;AACjB,eAAO,QAAQ;AAAA,MACjB;AAAA,IACF,CAAC;AAED,WAAO,MAAM;AACX,eAAS;AAAA,IACX;AAAA,EACF,GAAG,CAAC,iBAAiB,MAAM,CAAC;AAE5B,MAAI,gBAAiB,QAAO;AAC5B,SAAO,YAAY;AACrB;","names":[]}
|
package/dist/proxy/index.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
|
|
2
|
+
import { A as AccessTokenClaims } from '../types-CgrEUXwM.js';
|
|
3
|
+
export { L as LemonUser } from '../types-CgrEUXwM.js';
|
|
3
4
|
import 'jose';
|
|
4
5
|
|
|
5
6
|
interface AuthProxyOptions {
|
|
6
7
|
publicPaths?: string[];
|
|
7
8
|
clientId?: string;
|
|
8
9
|
loginRedirectUrl?: string;
|
|
10
|
+
onAuthSuccess?: (claims: AccessTokenClaims, request: NextRequest, response: NextResponse) => Promise<NextResponse> | NextResponse;
|
|
9
11
|
}
|
|
10
12
|
declare function createAuthProxy(options?: AuthProxyOptions): (request: NextRequest) => Promise<NextResponse>;
|
|
11
13
|
|
|
12
|
-
export { type AuthProxyOptions, createAuthProxy };
|
|
14
|
+
export { AccessTokenClaims, type AuthProxyOptions, createAuthProxy };
|
package/dist/proxy/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
|
13
13
|
var REFRESH_BUFFER_SECONDS = 60;
|
|
14
14
|
var jwks = createRemoteJWKSet(new URL(JWKS_URL));
|
|
15
15
|
function createAuthProxy(options = {}) {
|
|
16
|
-
const { publicPaths = [], clientId, loginRedirectUrl } = options;
|
|
16
|
+
const { publicPaths = [], clientId, loginRedirectUrl, onAuthSuccess } = options;
|
|
17
17
|
return async function proxy(request) {
|
|
18
18
|
const { pathname } = request.nextUrl;
|
|
19
19
|
if (isPublicPath(pathname, publicPaths)) {
|
|
@@ -27,7 +27,8 @@ function createAuthProxy(options = {}) {
|
|
|
27
27
|
if (clientId && !claims.approved_clients.includes(clientId)) {
|
|
28
28
|
return redirectToLogin(request, loginRedirectUrl);
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
const response = NextResponse.next();
|
|
31
|
+
return onAuthSuccess ? await onAuthSuccess(claims, request, response) : response;
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
if (refreshToken) {
|
|
@@ -42,7 +43,7 @@ function createAuthProxy(options = {}) {
|
|
|
42
43
|
for (const header of result.setCookieHeaders) {
|
|
43
44
|
response.headers.append("Set-Cookie", header);
|
|
44
45
|
}
|
|
45
|
-
return response;
|
|
46
|
+
return onAuthSuccess ? await onAuthSuccess(claims, request, response) : response;
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
}
|
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 AUTH_SERVER_URL,\n REFRESH_URL,\n JWKS_URL,\n LOGIN_URL,\n} from \"../constants.js\";\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport type { AccessTokenClaims } from \"../types.js\";\n\nconst REFRESH_BUFFER_SECONDS = 60;\n\nconst jwks = createRemoteJWKSet(new URL(JWKS_URL));\n\nexport interface AuthProxyOptions {\n publicPaths?: string[];\n clientId?: string;\n loginRedirectUrl?: string;\n}\n\nexport function createAuthProxy(options: AuthProxyOptions = {}) {\n const { publicPaths = [], clientId, loginRedirectUrl }
|
|
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 AUTH_SERVER_URL,\n REFRESH_URL,\n JWKS_URL,\n LOGIN_URL,\n} from \"../constants.js\";\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport type { AccessTokenClaims } from \"../types.js\";\n\nconst REFRESH_BUFFER_SECONDS = 60;\n\nconst jwks = createRemoteJWKSet(new URL(JWKS_URL));\n\nexport interface AuthProxyOptions {\n publicPaths?: string[];\n clientId?: string;\n loginRedirectUrl?: string;\n onAuthSuccess?: (\n claims: AccessTokenClaims,\n request: NextRequest,\n response: NextResponse\n ) => Promise<NextResponse> | NextResponse;\n}\n\nexport function createAuthProxy(options: AuthProxyOptions = {}) {\n const { publicPaths = [], clientId, loginRedirectUrl, onAuthSuccess } =\n options;\n\n return async function proxy(request: NextRequest): Promise<NextResponse> {\n const { pathname } = request.nextUrl;\n\n if (isPublicPath(pathname, publicPaths)) {\n return maybeRefreshAndContinue(request);\n }\n\n const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;\n const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;\n\n if (accessToken && isTokenFresh(accessToken)) {\n const claims = await verifyToken(accessToken);\n if (claims) {\n if (clientId && !claims.approved_clients.includes(clientId)) {\n return redirectToLogin(request, loginRedirectUrl);\n }\n const response = NextResponse.next();\n return onAuthSuccess\n ? await onAuthSuccess(claims, request, response)\n : response;\n }\n }\n\n if (refreshToken) {\n const result = await tryRefresh(refreshToken);\n if (result) {\n const claims = await verifyToken(result.newAccessToken);\n if (claims) {\n if (clientId && !claims.approved_clients.includes(clientId)) {\n return redirectToLogin(request, loginRedirectUrl);\n }\n const response = NextResponse.next();\n for (const header of result.setCookieHeaders) {\n response.headers.append(\"Set-Cookie\", header);\n }\n return onAuthSuccess\n ? await onAuthSuccess(claims, request, response)\n : response;\n }\n }\n }\n\n return redirectToLogin(request, loginRedirectUrl);\n };\n}\n\nasync function maybeRefreshAndContinue(\n request: NextRequest\n): Promise<NextResponse> {\n const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;\n const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;\n\n if (accessToken && isTokenFresh(accessToken)) {\n return NextResponse.next();\n }\n\n if (refreshToken) {\n const result = await tryRefresh(refreshToken);\n if (result) {\n const response = NextResponse.next();\n for (const header of result.setCookieHeaders) {\n response.headers.append(\"Set-Cookie\", header);\n }\n return response;\n }\n }\n\n return NextResponse.next();\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 );\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\nasync function verifyToken(\n token: string\n): Promise<AccessTokenClaims | null> {\n try {\n const { payload } = await jwtVerify(token, jwks, {\n issuer: AUTH_SERVER_URL,\n });\n return payload as unknown as AccessTokenClaims;\n } catch {\n return null;\n }\n}\n\ninterface RefreshSuccess {\n newAccessToken: string;\n setCookieHeaders: string[];\n}\n\nasync function tryRefresh(\n refreshToken: string\n): Promise<RefreshSuccess | null> {\n try {\n const res = await fetch(REFRESH_URL, {\n method: \"POST\",\n headers: { Cookie: `refresh_token=${refreshToken}` },\n });\n\n if (!res.ok) return null;\n\n const setCookieHeaders = res.headers.getSetCookie();\n const newAccessToken = extractAccessToken(setCookieHeaders);\n if (!newAccessToken) return null;\n\n return { newAccessToken, setCookieHeaders };\n } catch {\n return null;\n }\n}\n\nfunction extractAccessToken(setCookieHeaders: string[]): string | null {\n for (const header of setCookieHeaders) {\n const match = header.match(/^access_token=([^;]+)/);\n if (match) return match[1];\n }\n return null;\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?: string\n): NextResponse {\n if (loginRedirectUrl) {\n const url = `${LOGIN_URL}?redirect_url=${encodeURIComponent(loginRedirectUrl)}`;\n return NextResponse.redirect(new URL(url, request.url));\n }\n return NextResponse.redirect(new URL(\"/\", request.url));\n}\n\nexport type { AccessTokenClaims } from \"../types.js\";\nexport type { LemonUser } from \"../types.js\";\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lemondouble/lemon-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/LemonDouble/lemon-auth"
|
|
7
7
|
},
|
|
8
|
-
"description": "
|
|
8
|
+
"description": "*.lemondouble.com 서비스를 위한 인증 라이브러리",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"exports": {
|
|
11
11
|
"./server": {
|
|
@@ -24,12 +24,6 @@
|
|
|
24
24
|
"files": [
|
|
25
25
|
"dist"
|
|
26
26
|
],
|
|
27
|
-
"scripts": {
|
|
28
|
-
"build": "tsup",
|
|
29
|
-
"dev": "tsup --watch",
|
|
30
|
-
"typecheck": "tsc --noEmit",
|
|
31
|
-
"prepublishOnly": "npm run build"
|
|
32
|
-
},
|
|
33
27
|
"peerDependencies": {
|
|
34
28
|
"next": ">=16.0.0",
|
|
35
29
|
"react": ">=19.0.0"
|
|
@@ -50,5 +44,10 @@
|
|
|
50
44
|
"@types/react": "^19.2.14",
|
|
51
45
|
"tsup": "^8.5.1",
|
|
52
46
|
"typescript": "^6.0.3"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsup",
|
|
50
|
+
"dev": "tsup --watch",
|
|
51
|
+
"typecheck": "tsc --noEmit"
|
|
53
52
|
}
|
|
54
|
-
}
|
|
53
|
+
}
|