@lemondouble/lemon-auth 0.4.0 → 0.7.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 +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 +130 -21
- package/dist/proxy/index.js.map +1 -1
- package/dist/server/index.d.ts +16 -7
- 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 +1 -1
- package/dist/chunk-B2EFAFKY.js +0 -23
- package/dist/chunk-B2EFAFKY.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
|
package/dist/client/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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, useState } from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport { useAuth } from \"./context.js\";\nimport { refreshToken } from \"./url.js\";\n\nexport function AutoTokenRefresh({\n children,\n fallback,\n}: {\n children?: React.ReactNode;\n fallback: React.ReactNode;\n}) {\n const { isAuthenticated } = useAuth();\n const router = useRouter();\n const [status, setStatus] = useState<\"loading\" | \"failed\">(\"loading\");\n\n useEffect(() => {\n if (isAuthenticated) return;\n\n let ignore = false;\n\n refreshToken().then((ok) => {\n if (ignore) return;\n if (ok) {\n router.refresh();\n } else {\n setStatus(\"failed\");\n }\n });\n\n return () => {\n ignore = true;\n };\n }, [isAuthenticated, router]);\n\n if (isAuthenticated) return null;\n if (status === \"loading\") return fallback ?? 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<
|
|
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, useState } from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport { useAuth } from \"./context.js\";\nimport { refreshToken } from \"./url.js\";\n\nexport function AutoTokenRefresh({\n children,\n fallback,\n}: {\n children?: React.ReactNode;\n fallback: React.ReactNode;\n}) {\n const { isAuthenticated } = useAuth();\n const router = useRouter();\n const [status, setStatus] = useState<\"loading\" | \"failed\">(\"loading\");\n\n useEffect(() => {\n if (isAuthenticated) return;\n\n let ignore = false;\n\n refreshToken().then((ok) => {\n if (ignore) return;\n if (ok) {\n router.refresh();\n } else {\n setStatus(\"failed\");\n }\n });\n\n return () => {\n ignore = true;\n };\n }, [isAuthenticated, router]);\n\n if (isAuthenticated) return null;\n if (status === \"loading\") return fallback ?? null;\n return children ?? null;\n}\n","import {\n ACCESS_TOKEN_COOKIE,\n DEVICE_ID_COOKIE,\n LOGIN_URL,\n LOGOUT_URL,\n PROFILE_PAGE_URL,\n REFRESH_URL,\n REFRESH_TOKEN_COOKIE,\n} from \"../constants.js\";\nimport { isMockAuthEnabled } from \"../mock.js\";\n\nconst LOGOUT_COOKIE_NAMES = [\n ACCESS_TOKEN_COOKIE,\n REFRESH_TOKEN_COOKIE,\n DEVICE_ID_COOKIE,\n];\n\nexport function loginUrl(redirectUrl: string): string {\n if (isMockAuthEnabled()) return redirectUrl;\n assertValidLoginRedirectUrl(redirectUrl);\n return `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n}\n\nexport function profileUrl(redirectUrl?: string): string {\n if (isMockAuthEnabled()) return redirectUrl ?? \"/\";\n if (redirectUrl) {\n return `${PROFILE_PAGE_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n }\n return PROFILE_PAGE_URL;\n}\n\nexport async function logout(): Promise<boolean> {\n if (isMockAuthEnabled()) {\n expireAuthCookies();\n return true;\n }\n\n try {\n const res = await fetch(LOGOUT_URL, {\n method: \"POST\",\n credentials: \"include\",\n });\n return res.ok;\n } catch {\n return false;\n } finally {\n expireAuthCookies();\n }\n}\n\nexport async function refreshToken(): Promise<boolean> {\n if (isMockAuthEnabled()) return true;\n\n try {\n const res = await fetch(REFRESH_URL, {\n method: \"POST\",\n credentials: \"include\",\n });\n return res.ok;\n } catch {\n return false;\n }\n}\n\nfunction expireAuthCookies(): void {\n if (typeof document === \"undefined\") return;\n\n for (const name of LOGOUT_COOKIE_NAMES) {\n for (const domain of getCookieDomains()) {\n document.cookie = [\n `${name}=`,\n \"Max-Age=0\",\n \"Path=/\",\n \"SameSite=Lax\",\n domain ? `Domain=${domain}` : \"\",\n ]\n .filter(Boolean)\n .join(\"; \");\n }\n }\n}\n\nfunction getCookieDomains(): string[] {\n if (typeof window === \"undefined\") return [\"\"];\n\n const { hostname } = window.location;\n if (!hostname || hostname === \"localhost\" || isIpAddress(hostname)) {\n return [\"\"];\n }\n\n const labels = hostname.split(\".\");\n const domains = [\"\"];\n for (let i = 0; i < labels.length - 1; i += 1) {\n domains.push(`.${labels.slice(i).join(\".\")}`);\n }\n return domains;\n}\n\nfunction isIpAddress(hostname: string): boolean {\n return /^[\\d.]+$/.test(hostname) || hostname.includes(\":\");\n}\n\nfunction assertValidLoginRedirectUrl(redirectUrl: string): void {\n let url: URL;\n try {\n url = new URL(redirectUrl);\n } catch {\n throw new Error(\"loginUrl redirectUrl must be a valid absolute URL\");\n }\n\n if (url.protocol !== \"https:\") {\n throw new Error(\"loginUrl redirectUrl must use https\");\n }\n\n if (!isAllowedLoginRedirectHost(url.hostname)) {\n throw new Error(\n \"loginUrl redirectUrl host must be lemondouble.com or a subdomain of lemondouble.com\"\n );\n }\n}\n\nfunction isAllowedLoginRedirectHost(hostname: string): boolean {\n const normalized = hostname.toLowerCase();\n return (\n normalized === \"lemondouble.com\" ||\n normalized.endsWith(\".lemondouble.com\")\n );\n}\n"],"mappings":"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","names":[]}
|
package/dist/proxy/index.d.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
import { A as AccessTokenClaims } from '../types-
|
|
3
|
-
export {
|
|
2
|
+
import { A as AccessTokenClaims } from '../types-B5uRY2Si.js';
|
|
3
|
+
export { a as LemonUser } from '../types-B5uRY2Si.js';
|
|
4
4
|
import 'jose';
|
|
5
5
|
|
|
6
|
+
declare const DEFAULT_AUTH_BYPASS_PATHS: string[];
|
|
7
|
+
declare const DEFAULT_API_PATHS: string[];
|
|
8
|
+
declare const PROXY_AUTH_ERROR: {
|
|
9
|
+
readonly UNAUTHORIZED: "UNAUTHORIZED";
|
|
10
|
+
readonly FORBIDDEN: "FORBIDDEN";
|
|
11
|
+
};
|
|
12
|
+
type ProxyAuthErrorCode = (typeof PROXY_AUTH_ERROR)[keyof typeof PROXY_AUTH_ERROR];
|
|
13
|
+
type LoginRedirectUrl = string | ((request: NextRequest) => string);
|
|
6
14
|
interface AuthProxyOptions {
|
|
7
15
|
publicPaths?: string[];
|
|
16
|
+
bypassPaths?: string[];
|
|
17
|
+
apiPaths?: string[];
|
|
8
18
|
clientId?: string;
|
|
9
|
-
loginRedirectUrl?:
|
|
19
|
+
loginRedirectUrl?: LoginRedirectUrl;
|
|
20
|
+
unapprovedRedirectUrl?: LoginRedirectUrl;
|
|
10
21
|
onAuthSuccess?: (claims: AccessTokenClaims, request: NextRequest, response: NextResponse) => Promise<NextResponse> | NextResponse;
|
|
11
22
|
}
|
|
12
23
|
declare function createAuthProxy(options?: AuthProxyOptions): (request: NextRequest) => Promise<NextResponse>;
|
|
13
24
|
|
|
14
|
-
export { AccessTokenClaims, type AuthProxyOptions, createAuthProxy };
|
|
25
|
+
export { AccessTokenClaims, type AuthProxyOptions, DEFAULT_API_PATHS, DEFAULT_AUTH_BYPASS_PATHS, type LoginRedirectUrl, PROXY_AUTH_ERROR, type ProxyAuthErrorCode, createAuthProxy };
|
package/dist/proxy/index.js
CHANGED
|
@@ -1,63 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isAccessTokenClaims,
|
|
3
|
+
isApprovedClient
|
|
4
|
+
} from "../chunk-6W24RWSJ.js";
|
|
1
5
|
import {
|
|
2
6
|
ACCESS_TOKEN_COOKIE,
|
|
3
7
|
AUTH_SERVER_URL,
|
|
8
|
+
DEVICE_ID_COOKIE,
|
|
4
9
|
JWKS_URL,
|
|
5
10
|
LOGIN_URL,
|
|
6
11
|
REFRESH_TOKEN_COOKIE,
|
|
7
|
-
REFRESH_URL
|
|
8
|
-
|
|
12
|
+
REFRESH_URL,
|
|
13
|
+
getMockAccessTokenClaims
|
|
14
|
+
} from "../chunk-OX5MDVSR.js";
|
|
9
15
|
|
|
10
16
|
// src/proxy/index.ts
|
|
11
17
|
import { NextResponse } from "next/server";
|
|
12
18
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
13
19
|
var REFRESH_BUFFER_SECONDS = 60;
|
|
20
|
+
var DEFAULT_AUTH_BYPASS_PATHS = [
|
|
21
|
+
"/sw.js",
|
|
22
|
+
"/service-worker.js",
|
|
23
|
+
"/manifest.webmanifest",
|
|
24
|
+
"/manifest.json",
|
|
25
|
+
"/favicon.ico",
|
|
26
|
+
"/robots.txt",
|
|
27
|
+
"/sitemap.xml",
|
|
28
|
+
"/browserconfig.xml",
|
|
29
|
+
"/apple-touch-icon.png",
|
|
30
|
+
"/apple-touch-icon-*",
|
|
31
|
+
"/icon-*",
|
|
32
|
+
"/icons/*"
|
|
33
|
+
];
|
|
34
|
+
var DEFAULT_API_PATHS = ["/api/*"];
|
|
35
|
+
var PROXY_AUTH_ERROR = {
|
|
36
|
+
UNAUTHORIZED: "UNAUTHORIZED",
|
|
37
|
+
FORBIDDEN: "FORBIDDEN"
|
|
38
|
+
};
|
|
14
39
|
var jwks = createRemoteJWKSet(new URL(JWKS_URL));
|
|
15
40
|
function createAuthProxy(options = {}) {
|
|
16
|
-
const {
|
|
41
|
+
const {
|
|
42
|
+
publicPaths = [],
|
|
43
|
+
bypassPaths = [],
|
|
44
|
+
apiPaths = DEFAULT_API_PATHS,
|
|
45
|
+
clientId,
|
|
46
|
+
loginRedirectUrl,
|
|
47
|
+
unapprovedRedirectUrl,
|
|
48
|
+
onAuthSuccess
|
|
49
|
+
} = options;
|
|
50
|
+
const effectiveBypassPaths = [
|
|
51
|
+
...DEFAULT_AUTH_BYPASS_PATHS,
|
|
52
|
+
...bypassPaths
|
|
53
|
+
];
|
|
17
54
|
return async function proxy(request) {
|
|
18
55
|
const { pathname } = request.nextUrl;
|
|
56
|
+
if (isPublicPath(pathname, effectiveBypassPaths)) {
|
|
57
|
+
return NextResponse.next();
|
|
58
|
+
}
|
|
19
59
|
if (isPublicPath(pathname, publicPaths)) {
|
|
20
60
|
return maybeRefreshAndContinue(request);
|
|
21
61
|
}
|
|
62
|
+
const isApiRequest = isPublicPath(pathname, apiPaths);
|
|
63
|
+
const mockClaims = getMockAccessTokenClaims();
|
|
64
|
+
if (mockClaims) {
|
|
65
|
+
return handleVerifiedClaims(
|
|
66
|
+
mockClaims,
|
|
67
|
+
request,
|
|
68
|
+
isApiRequest,
|
|
69
|
+
clientId,
|
|
70
|
+
unapprovedRedirectUrl,
|
|
71
|
+
onAuthSuccess,
|
|
72
|
+
NextResponse.next()
|
|
73
|
+
);
|
|
74
|
+
}
|
|
22
75
|
const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;
|
|
23
76
|
const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;
|
|
77
|
+
const deviceId = request.cookies.get(DEVICE_ID_COOKIE)?.value;
|
|
24
78
|
if (accessToken && isTokenFresh(accessToken)) {
|
|
25
79
|
const claims = await verifyToken(accessToken);
|
|
26
80
|
if (claims) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
81
|
+
return handleVerifiedClaims(
|
|
82
|
+
claims,
|
|
83
|
+
request,
|
|
84
|
+
isApiRequest,
|
|
85
|
+
clientId,
|
|
86
|
+
unapprovedRedirectUrl,
|
|
87
|
+
onAuthSuccess,
|
|
88
|
+
NextResponse.next()
|
|
89
|
+
);
|
|
32
90
|
}
|
|
33
91
|
}
|
|
34
92
|
if (refreshToken) {
|
|
35
|
-
const result = await tryRefresh(refreshToken);
|
|
93
|
+
const result = await tryRefresh(refreshToken, deviceId);
|
|
36
94
|
if (result) {
|
|
37
95
|
const claims = await verifyToken(result.newAccessToken);
|
|
38
96
|
if (claims) {
|
|
39
|
-
if (clientId && !claims.approved_clients.includes(clientId)) {
|
|
40
|
-
return redirectToLogin(request, loginRedirectUrl);
|
|
41
|
-
}
|
|
42
97
|
const response = NextResponse.next();
|
|
43
98
|
for (const header of result.setCookieHeaders) {
|
|
44
99
|
response.headers.append("Set-Cookie", header);
|
|
45
100
|
}
|
|
46
|
-
return
|
|
101
|
+
return handleVerifiedClaims(
|
|
102
|
+
claims,
|
|
103
|
+
request,
|
|
104
|
+
isApiRequest,
|
|
105
|
+
clientId,
|
|
106
|
+
unapprovedRedirectUrl,
|
|
107
|
+
onAuthSuccess,
|
|
108
|
+
response
|
|
109
|
+
);
|
|
47
110
|
}
|
|
48
111
|
}
|
|
49
112
|
}
|
|
50
|
-
return redirectToLogin(request, loginRedirectUrl);
|
|
113
|
+
return isApiRequest ? unauthorizedJson() : redirectToLogin(request, loginRedirectUrl);
|
|
51
114
|
};
|
|
52
115
|
}
|
|
116
|
+
async function handleVerifiedClaims(claims, request, isApiRequest, clientId, unapprovedRedirectUrl, onAuthSuccess, response) {
|
|
117
|
+
if (!isApprovedClient(claims.approved_clients, clientId)) {
|
|
118
|
+
return isApiRequest ? forbiddenJson() : redirectToUnapproved(request, unapprovedRedirectUrl);
|
|
119
|
+
}
|
|
120
|
+
return onAuthSuccess ? await onAuthSuccess(claims, request, response) : response;
|
|
121
|
+
}
|
|
122
|
+
function unauthorizedJson() {
|
|
123
|
+
return NextResponse.json(
|
|
124
|
+
{ code: PROXY_AUTH_ERROR.UNAUTHORIZED },
|
|
125
|
+
{ status: 401 }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
function forbiddenJson() {
|
|
129
|
+
return NextResponse.json(
|
|
130
|
+
{ code: PROXY_AUTH_ERROR.FORBIDDEN },
|
|
131
|
+
{ status: 403 }
|
|
132
|
+
);
|
|
133
|
+
}
|
|
53
134
|
async function maybeRefreshAndContinue(request) {
|
|
54
135
|
const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;
|
|
55
136
|
const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;
|
|
137
|
+
const deviceId = request.cookies.get(DEVICE_ID_COOKIE)?.value;
|
|
56
138
|
if (accessToken && isTokenFresh(accessToken)) {
|
|
57
139
|
return NextResponse.next();
|
|
58
140
|
}
|
|
59
141
|
if (refreshToken) {
|
|
60
|
-
const result = await tryRefresh(refreshToken);
|
|
142
|
+
const result = await tryRefresh(refreshToken, deviceId);
|
|
61
143
|
if (result) {
|
|
62
144
|
const response = NextResponse.next();
|
|
63
145
|
for (const header of result.setCookieHeaders) {
|
|
@@ -85,16 +167,16 @@ async function verifyToken(token) {
|
|
|
85
167
|
const { payload } = await jwtVerify(token, jwks, {
|
|
86
168
|
issuer: AUTH_SERVER_URL
|
|
87
169
|
});
|
|
88
|
-
return payload;
|
|
170
|
+
return isAccessTokenClaims(payload) ? payload : null;
|
|
89
171
|
} catch {
|
|
90
172
|
return null;
|
|
91
173
|
}
|
|
92
174
|
}
|
|
93
|
-
async function tryRefresh(refreshToken) {
|
|
175
|
+
async function tryRefresh(refreshToken, deviceId) {
|
|
94
176
|
try {
|
|
95
177
|
const res = await fetch(REFRESH_URL, {
|
|
96
178
|
method: "POST",
|
|
97
|
-
headers: { Cookie:
|
|
179
|
+
headers: { Cookie: buildRefreshCookieHeader(refreshToken, deviceId) }
|
|
98
180
|
});
|
|
99
181
|
if (!res.ok) return null;
|
|
100
182
|
const setCookieHeaders = res.headers.getSetCookie();
|
|
@@ -105,9 +187,16 @@ async function tryRefresh(refreshToken) {
|
|
|
105
187
|
return null;
|
|
106
188
|
}
|
|
107
189
|
}
|
|
190
|
+
function buildRefreshCookieHeader(refreshToken, deviceId) {
|
|
191
|
+
const cookies = [`${REFRESH_TOKEN_COOKIE}=${refreshToken}`];
|
|
192
|
+
if (deviceId) {
|
|
193
|
+
cookies.push(`${DEVICE_ID_COOKIE}=${deviceId}`);
|
|
194
|
+
}
|
|
195
|
+
return cookies.join("; ");
|
|
196
|
+
}
|
|
108
197
|
function extractAccessToken(setCookieHeaders) {
|
|
109
198
|
for (const header of setCookieHeaders) {
|
|
110
|
-
const match = header.match(
|
|
199
|
+
const match = header.match(new RegExp(`^${ACCESS_TOKEN_COOKIE}=([^;]+)`));
|
|
111
200
|
if (match) return match[1];
|
|
112
201
|
}
|
|
113
202
|
return null;
|
|
@@ -121,13 +210,33 @@ function isPublicPath(pathname, publicPaths) {
|
|
|
121
210
|
});
|
|
122
211
|
}
|
|
123
212
|
function redirectToLogin(request, loginRedirectUrl) {
|
|
124
|
-
|
|
125
|
-
|
|
213
|
+
const redirectUrl = resolveLoginRedirectUrl(request, loginRedirectUrl);
|
|
214
|
+
if (redirectUrl) {
|
|
215
|
+
const url = `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;
|
|
126
216
|
return NextResponse.redirect(new URL(url, request.url));
|
|
127
217
|
}
|
|
128
218
|
return NextResponse.redirect(new URL("/", request.url));
|
|
129
219
|
}
|
|
220
|
+
function redirectToUnapproved(request, unapprovedRedirectUrl) {
|
|
221
|
+
const redirectUrl = resolveLoginRedirectUrl(request, unapprovedRedirectUrl);
|
|
222
|
+
if (redirectUrl) {
|
|
223
|
+
return NextResponse.redirect(new URL(redirectUrl, request.url));
|
|
224
|
+
}
|
|
225
|
+
const errorUrl = new URL(`${AUTH_SERVER_URL}/error`);
|
|
226
|
+
errorUrl.searchParams.set("code", PROXY_AUTH_ERROR.FORBIDDEN);
|
|
227
|
+
errorUrl.searchParams.set("from", request.url);
|
|
228
|
+
return NextResponse.redirect(errorUrl);
|
|
229
|
+
}
|
|
230
|
+
function resolveLoginRedirectUrl(request, loginRedirectUrl) {
|
|
231
|
+
if (typeof loginRedirectUrl === "function") {
|
|
232
|
+
return loginRedirectUrl(request);
|
|
233
|
+
}
|
|
234
|
+
return loginRedirectUrl;
|
|
235
|
+
}
|
|
130
236
|
export {
|
|
237
|
+
DEFAULT_API_PATHS,
|
|
238
|
+
DEFAULT_AUTH_BYPASS_PATHS,
|
|
239
|
+
PROXY_AUTH_ERROR,
|
|
131
240
|
createAuthProxy
|
|
132
241
|
};
|
|
133
242
|
//# sourceMappingURL=index.js.map
|
package/dist/proxy/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/proxy/index.ts"],"sourcesContent":["import { NextRequest, NextResponse } from \"next/server\";\nimport {\n ACCESS_TOKEN_COOKIE,\n REFRESH_TOKEN_COOKIE,\n 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":[]}
|
|
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 DEVICE_ID_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\";\nimport { isAccessTokenClaims } from \"../claims.js\";\nimport { isApprovedClient } from \"../approval.js\";\nimport { getMockAccessTokenClaims } from \"../mock.js\";\n\nconst REFRESH_BUFFER_SECONDS = 60;\n\nexport const DEFAULT_AUTH_BYPASS_PATHS = [\n \"/sw.js\",\n \"/service-worker.js\",\n \"/manifest.webmanifest\",\n \"/manifest.json\",\n \"/favicon.ico\",\n \"/robots.txt\",\n \"/sitemap.xml\",\n \"/browserconfig.xml\",\n \"/apple-touch-icon.png\",\n \"/apple-touch-icon-*\",\n \"/icon-*\",\n \"/icons/*\",\n];\n\nexport const DEFAULT_API_PATHS = [\"/api/*\"];\n\nexport const PROXY_AUTH_ERROR = {\n UNAUTHORIZED: \"UNAUTHORIZED\",\n FORBIDDEN: \"FORBIDDEN\",\n} as const;\nexport type ProxyAuthErrorCode =\n (typeof PROXY_AUTH_ERROR)[keyof typeof PROXY_AUTH_ERROR];\n\nconst jwks = createRemoteJWKSet(new URL(JWKS_URL));\n\nexport type LoginRedirectUrl = string | ((request: NextRequest) => string);\n\nexport interface AuthProxyOptions {\n publicPaths?: string[];\n bypassPaths?: string[];\n apiPaths?: string[];\n clientId?: string;\n loginRedirectUrl?: LoginRedirectUrl;\n unapprovedRedirectUrl?: LoginRedirectUrl;\n onAuthSuccess?: (\n claims: AccessTokenClaims,\n request: NextRequest,\n response: NextResponse\n ) => Promise<NextResponse> | NextResponse;\n}\n\nexport function createAuthProxy(options: AuthProxyOptions = {}) {\n const {\n publicPaths = [],\n bypassPaths = [],\n apiPaths = DEFAULT_API_PATHS,\n clientId,\n loginRedirectUrl,\n unapprovedRedirectUrl,\n onAuthSuccess,\n } = options;\n const effectiveBypassPaths = [\n ...DEFAULT_AUTH_BYPASS_PATHS,\n ...bypassPaths,\n ];\n\n return async function proxy(request: NextRequest): Promise<NextResponse> {\n const { pathname } = request.nextUrl;\n\n if (isPublicPath(pathname, effectiveBypassPaths)) {\n return NextResponse.next();\n }\n\n if (isPublicPath(pathname, publicPaths)) {\n return maybeRefreshAndContinue(request);\n }\n\n const isApiRequest = isPublicPath(pathname, apiPaths);\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) {\n return handleVerifiedClaims(\n mockClaims,\n request,\n isApiRequest,\n clientId,\n unapprovedRedirectUrl,\n onAuthSuccess,\n NextResponse.next()\n );\n }\n\n const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;\n const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;\n const deviceId = request.cookies.get(DEVICE_ID_COOKIE)?.value;\n\n if (accessToken && isTokenFresh(accessToken)) {\n const claims = await verifyToken(accessToken);\n if (claims) {\n return handleVerifiedClaims(\n claims,\n request,\n isApiRequest,\n clientId,\n unapprovedRedirectUrl,\n onAuthSuccess,\n NextResponse.next()\n );\n }\n }\n\n if (refreshToken) {\n const result = await tryRefresh(refreshToken, deviceId);\n if (result) {\n const claims = await verifyToken(result.newAccessToken);\n if (claims) {\n const response = NextResponse.next();\n for (const header of result.setCookieHeaders) {\n response.headers.append(\"Set-Cookie\", header);\n }\n return handleVerifiedClaims(\n claims,\n request,\n isApiRequest,\n clientId,\n unapprovedRedirectUrl,\n onAuthSuccess,\n response\n );\n }\n }\n }\n\n return isApiRequest\n ? unauthorizedJson()\n : redirectToLogin(request, loginRedirectUrl);\n };\n}\n\nasync function handleVerifiedClaims(\n claims: AccessTokenClaims,\n request: NextRequest,\n isApiRequest: boolean,\n clientId: string | undefined,\n unapprovedRedirectUrl: LoginRedirectUrl | undefined,\n onAuthSuccess: AuthProxyOptions[\"onAuthSuccess\"],\n response: NextResponse\n): Promise<NextResponse> {\n if (!isApprovedClient(claims.approved_clients, clientId)) {\n return isApiRequest\n ? forbiddenJson()\n : redirectToUnapproved(request, unapprovedRedirectUrl);\n }\n\n return onAuthSuccess\n ? await onAuthSuccess(claims, request, response)\n : response;\n}\n\nfunction unauthorizedJson(): NextResponse {\n return NextResponse.json(\n { code: PROXY_AUTH_ERROR.UNAUTHORIZED },\n { status: 401 }\n );\n}\n\nfunction forbiddenJson(): NextResponse {\n return NextResponse.json(\n { code: PROXY_AUTH_ERROR.FORBIDDEN },\n { status: 403 }\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 const deviceId = request.cookies.get(DEVICE_ID_COOKIE)?.value;\n\n if (accessToken && isTokenFresh(accessToken)) {\n return NextResponse.next();\n }\n\n if (refreshToken) {\n const result = await tryRefresh(refreshToken, deviceId);\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 isAccessTokenClaims(payload) ? payload : null;\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 deviceId?: string\n): Promise<RefreshSuccess | null> {\n try {\n const res = await fetch(REFRESH_URL, {\n method: \"POST\",\n headers: { Cookie: buildRefreshCookieHeader(refreshToken, deviceId) },\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 buildRefreshCookieHeader(\n refreshToken: string,\n deviceId?: string\n): string {\n const cookies = [`${REFRESH_TOKEN_COOKIE}=${refreshToken}`];\n if (deviceId) {\n cookies.push(`${DEVICE_ID_COOKIE}=${deviceId}`);\n }\n return cookies.join(\"; \");\n}\n\nfunction extractAccessToken(setCookieHeaders: string[]): string | null {\n for (const header of setCookieHeaders) {\n const match = header.match(new RegExp(`^${ACCESS_TOKEN_COOKIE}=([^;]+)`));\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?: LoginRedirectUrl\n): NextResponse {\n const redirectUrl = resolveLoginRedirectUrl(request, loginRedirectUrl);\n if (redirectUrl) {\n const url = `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n return NextResponse.redirect(new URL(url, request.url));\n }\n return NextResponse.redirect(new URL(\"/\", request.url));\n}\n\nfunction redirectToUnapproved(\n request: NextRequest,\n unapprovedRedirectUrl?: LoginRedirectUrl\n): NextResponse {\n const redirectUrl = resolveLoginRedirectUrl(request, unapprovedRedirectUrl);\n if (redirectUrl) {\n return NextResponse.redirect(new URL(redirectUrl, request.url));\n }\n const errorUrl = new URL(`${AUTH_SERVER_URL}/error`);\n errorUrl.searchParams.set(\"code\", PROXY_AUTH_ERROR.FORBIDDEN);\n errorUrl.searchParams.set(\"from\", request.url);\n return NextResponse.redirect(errorUrl);\n}\n\nfunction resolveLoginRedirectUrl(\n request: NextRequest,\n loginRedirectUrl?: LoginRedirectUrl\n): string | undefined {\n if (typeof loginRedirectUrl === \"function\") {\n return loginRedirectUrl(request);\n }\n return loginRedirectUrl;\n}\n\nexport type { AccessTokenClaims } from \"../types.js\";\nexport type { LemonUser } from \"../types.js\";\n"],"mappings":"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","names":[]}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
|
-
import { A as AccessTokenClaims, L as LemonUser } from '../types-
|
|
2
|
-
export { U as UserProfile } from '../types-
|
|
1
|
+
import { A as AccessTokenClaims, L as LemonSession, a as LemonUser } from '../types-B5uRY2Si.js';
|
|
2
|
+
export { U as UserProfile } from '../types-B5uRY2Si.js';
|
|
3
3
|
import 'jose';
|
|
4
4
|
|
|
5
5
|
declare function verifyAccessToken(): Promise<AccessTokenClaims | null>;
|
|
6
6
|
declare function verifyAccessTokenString(token: string): Promise<AccessTokenClaims | null>;
|
|
7
7
|
|
|
8
8
|
declare const getUser: () => Promise<LemonUser | null>;
|
|
9
|
+
interface GetSessionOptions {
|
|
10
|
+
clientId?: string;
|
|
11
|
+
}
|
|
12
|
+
declare function getSession(options?: GetSessionOptions): Promise<LemonSession>;
|
|
9
13
|
declare function requireAuth(redirectTo?: string): Promise<LemonUser>;
|
|
10
|
-
|
|
14
|
+
interface RequireClientOptions {
|
|
15
|
+
loginRedirectTo?: string;
|
|
16
|
+
unapprovedRedirectTo?: string;
|
|
17
|
+
}
|
|
18
|
+
declare function requireClient(clientId: string, optionsOrRedirectTo?: string | RequireClientOptions): Promise<LemonUser>;
|
|
11
19
|
|
|
12
20
|
interface RefreshResult {
|
|
13
21
|
ok: boolean;
|
|
14
22
|
setCookieHeaders: string[];
|
|
15
23
|
}
|
|
16
|
-
declare function refreshTokenFromCookie(refreshTokenCookie: string): Promise<RefreshResult>;
|
|
24
|
+
declare function refreshTokenFromCookie(refreshTokenCookie: string, deviceIdCookie?: string): Promise<RefreshResult>;
|
|
17
25
|
|
|
18
26
|
declare const AUTH_SERVER_URL = "https://auth.lemondouble.com";
|
|
19
27
|
declare const JWKS_URL = "https://auth.lemondouble.com/.well-known/jwks.json";
|
|
@@ -22,7 +30,8 @@ declare const REFRESH_URL = "https://auth.lemondouble.com/api/token/refresh";
|
|
|
22
30
|
declare const LOGOUT_URL = "https://auth.lemondouble.com/api/token/logout";
|
|
23
31
|
declare const PROFILE_URL = "https://auth.lemondouble.com/api/user/me";
|
|
24
32
|
declare const PROFILE_PAGE_URL = "https://auth.lemondouble.com/profile";
|
|
25
|
-
declare const ACCESS_TOKEN_COOKIE = "
|
|
26
|
-
declare const REFRESH_TOKEN_COOKIE = "
|
|
33
|
+
declare const ACCESS_TOKEN_COOKIE = "lemon_access_token";
|
|
34
|
+
declare const REFRESH_TOKEN_COOKIE = "lemon_refresh_token";
|
|
35
|
+
declare const DEVICE_ID_COOKIE = "device_id";
|
|
27
36
|
|
|
28
|
-
export { ACCESS_TOKEN_COOKIE, AUTH_SERVER_URL, AccessTokenClaims, JWKS_URL, LOGIN_URL, LOGOUT_URL, LemonUser, PROFILE_PAGE_URL, PROFILE_URL, REFRESH_TOKEN_COOKIE, REFRESH_URL, type RefreshResult, getUser, refreshTokenFromCookie, requireAuth, requireClient, verifyAccessToken, verifyAccessTokenString };
|
|
37
|
+
export { ACCESS_TOKEN_COOKIE, AUTH_SERVER_URL, AccessTokenClaims, DEVICE_ID_COOKIE, type GetSessionOptions, JWKS_URL, LOGIN_URL, LOGOUT_URL, LemonSession, LemonUser, PROFILE_PAGE_URL, PROFILE_URL, REFRESH_TOKEN_COOKIE, REFRESH_URL, type RefreshResult, type RequireClientOptions, getSession, getUser, refreshTokenFromCookie, requireAuth, requireClient, verifyAccessToken, verifyAccessTokenString };
|
package/dist/server/index.js
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isAccessTokenClaims,
|
|
3
|
+
isApprovedClient
|
|
4
|
+
} from "../chunk-6W24RWSJ.js";
|
|
1
5
|
import {
|
|
2
6
|
ACCESS_TOKEN_COOKIE,
|
|
3
7
|
AUTH_SERVER_URL,
|
|
8
|
+
DEVICE_ID_COOKIE,
|
|
4
9
|
JWKS_URL,
|
|
5
10
|
LOGIN_URL,
|
|
6
11
|
LOGOUT_URL,
|
|
7
12
|
PROFILE_PAGE_URL,
|
|
8
13
|
PROFILE_URL,
|
|
9
14
|
REFRESH_TOKEN_COOKIE,
|
|
10
|
-
REFRESH_URL
|
|
11
|
-
|
|
15
|
+
REFRESH_URL,
|
|
16
|
+
getMockAccessTokenClaims,
|
|
17
|
+
isMockAuthEnabled
|
|
18
|
+
} from "../chunk-OX5MDVSR.js";
|
|
12
19
|
|
|
13
20
|
// src/server/verify.ts
|
|
14
21
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
15
22
|
import { cookies } from "next/headers";
|
|
16
23
|
var jwks = createRemoteJWKSet(new URL(JWKS_URL));
|
|
17
24
|
async function verifyAccessToken() {
|
|
25
|
+
const mockClaims = getMockAccessTokenClaims();
|
|
26
|
+
if (mockClaims) return mockClaims;
|
|
18
27
|
const cookieStore = await cookies();
|
|
19
28
|
const token = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
|
|
20
29
|
if (!token) return null;
|
|
@@ -22,17 +31,19 @@ async function verifyAccessToken() {
|
|
|
22
31
|
const { payload } = await jwtVerify(token, jwks, {
|
|
23
32
|
issuer: AUTH_SERVER_URL
|
|
24
33
|
});
|
|
25
|
-
return payload;
|
|
34
|
+
return isAccessTokenClaims(payload) ? payload : null;
|
|
26
35
|
} catch {
|
|
27
36
|
return null;
|
|
28
37
|
}
|
|
29
38
|
}
|
|
30
39
|
async function verifyAccessTokenString(token) {
|
|
40
|
+
const mockClaims = getMockAccessTokenClaims();
|
|
41
|
+
if (mockClaims) return mockClaims;
|
|
31
42
|
try {
|
|
32
43
|
const { payload } = await jwtVerify(token, jwks, {
|
|
33
44
|
issuer: AUTH_SERVER_URL
|
|
34
45
|
});
|
|
35
|
-
return payload;
|
|
46
|
+
return isAccessTokenClaims(payload) ? payload : null;
|
|
36
47
|
} catch {
|
|
37
48
|
return null;
|
|
38
49
|
}
|
|
@@ -55,25 +66,47 @@ var getUser = cache(async () => {
|
|
|
55
66
|
if (!claims) return null;
|
|
56
67
|
return claimsToUser(claims);
|
|
57
68
|
});
|
|
69
|
+
var getSessionByClientId = cache(
|
|
70
|
+
async (clientId) => {
|
|
71
|
+
const user = await getUser();
|
|
72
|
+
if (!user) return { type: "none" };
|
|
73
|
+
if (!isApprovedClient(user.approvedClients, clientId)) {
|
|
74
|
+
return { type: "unapproved", user };
|
|
75
|
+
}
|
|
76
|
+
return { type: "authenticated", user };
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
async function getSession(options = {}) {
|
|
80
|
+
return getSessionByClientId(options.clientId);
|
|
81
|
+
}
|
|
58
82
|
async function requireAuth(redirectTo = "/") {
|
|
59
83
|
const user = await getUser();
|
|
60
84
|
if (user) return user;
|
|
61
85
|
redirect(redirectTo);
|
|
62
86
|
}
|
|
63
|
-
async function requireClient(clientId,
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
87
|
+
async function requireClient(clientId, optionsOrRedirectTo = "/") {
|
|
88
|
+
const options = typeof optionsOrRedirectTo === "string" ? { loginRedirectTo: optionsOrRedirectTo } : optionsOrRedirectTo;
|
|
89
|
+
const session = await getSession({ clientId });
|
|
90
|
+
if (session.type === "none") {
|
|
91
|
+
redirect(options.loginRedirectTo ?? "/");
|
|
67
92
|
}
|
|
68
|
-
|
|
93
|
+
if (session.type === "unapproved") {
|
|
94
|
+
redirect(
|
|
95
|
+
options.unapprovedRedirectTo ?? `${AUTH_SERVER_URL}/error?code=FORBIDDEN`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return session.user;
|
|
69
99
|
}
|
|
70
100
|
|
|
71
101
|
// src/server/refresh.ts
|
|
72
|
-
async function refreshTokenFromCookie(refreshTokenCookie) {
|
|
102
|
+
async function refreshTokenFromCookie(refreshTokenCookie, deviceIdCookie) {
|
|
103
|
+
if (isMockAuthEnabled()) {
|
|
104
|
+
return { ok: true, setCookieHeaders: [] };
|
|
105
|
+
}
|
|
73
106
|
const res = await fetch(REFRESH_URL, {
|
|
74
107
|
method: "POST",
|
|
75
108
|
headers: {
|
|
76
|
-
Cookie:
|
|
109
|
+
Cookie: buildRefreshCookieHeader(refreshTokenCookie, deviceIdCookie)
|
|
77
110
|
}
|
|
78
111
|
});
|
|
79
112
|
if (!res.ok) {
|
|
@@ -82,9 +115,17 @@ async function refreshTokenFromCookie(refreshTokenCookie) {
|
|
|
82
115
|
const setCookieHeaders = res.headers.getSetCookie();
|
|
83
116
|
return { ok: true, setCookieHeaders };
|
|
84
117
|
}
|
|
118
|
+
function buildRefreshCookieHeader(refreshTokenCookie, deviceIdCookie) {
|
|
119
|
+
const cookies2 = [`${REFRESH_TOKEN_COOKIE}=${refreshTokenCookie}`];
|
|
120
|
+
if (deviceIdCookie) {
|
|
121
|
+
cookies2.push(`${DEVICE_ID_COOKIE}=${deviceIdCookie}`);
|
|
122
|
+
}
|
|
123
|
+
return cookies2.join("; ");
|
|
124
|
+
}
|
|
85
125
|
export {
|
|
86
126
|
ACCESS_TOKEN_COOKIE,
|
|
87
127
|
AUTH_SERVER_URL,
|
|
128
|
+
DEVICE_ID_COOKIE,
|
|
88
129
|
JWKS_URL,
|
|
89
130
|
LOGIN_URL,
|
|
90
131
|
LOGOUT_URL,
|
|
@@ -92,6 +133,7 @@ export {
|
|
|
92
133
|
PROFILE_URL,
|
|
93
134
|
REFRESH_TOKEN_COOKIE,
|
|
94
135
|
REFRESH_URL,
|
|
136
|
+
getSession,
|
|
95
137
|
getUser,
|
|
96
138
|
refreshTokenFromCookie,
|
|
97
139
|
requireAuth,
|
package/dist/server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/server/verify.ts","../../src/server/user.ts","../../src/server/refresh.ts"],"sourcesContent":["import { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport { cookies } from \"next/headers\";\nimport type { AccessTokenClaims } from \"../types.js\";\nimport { JWKS_URL, AUTH_SERVER_URL, ACCESS_TOKEN_COOKIE } from \"../constants.js\";\n\nconst jwks = createRemoteJWKSet(new URL(JWKS_URL));\n\nexport async function verifyAccessToken(): Promise<AccessTokenClaims | null> {\n const cookieStore = await cookies();\n const token = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;\n if (!token) return null;\n\n try {\n const { payload } = await jwtVerify(token, jwks, {\n issuer: AUTH_SERVER_URL,\n });\n return payload
|
|
1
|
+
{"version":3,"sources":["../../src/server/verify.ts","../../src/server/user.ts","../../src/server/refresh.ts"],"sourcesContent":["import { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport { cookies } from \"next/headers\";\nimport type { AccessTokenClaims } from \"../types.js\";\nimport { JWKS_URL, AUTH_SERVER_URL, ACCESS_TOKEN_COOKIE } from \"../constants.js\";\nimport { isAccessTokenClaims } from \"../claims.js\";\nimport { getMockAccessTokenClaims } from \"../mock.js\";\n\nconst jwks = createRemoteJWKSet(new URL(JWKS_URL));\n\nexport async function verifyAccessToken(): Promise<AccessTokenClaims | null> {\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) return mockClaims;\n\n const cookieStore = await cookies();\n const token = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;\n if (!token) return null;\n\n try {\n const { payload } = await jwtVerify(token, jwks, {\n issuer: AUTH_SERVER_URL,\n });\n return isAccessTokenClaims(payload) ? payload : null;\n } catch {\n return null;\n }\n}\n\nexport async function verifyAccessTokenString(\n token: string\n): Promise<AccessTokenClaims | null> {\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) return mockClaims;\n\n try {\n const { payload } = await jwtVerify(token, jwks, {\n issuer: AUTH_SERVER_URL,\n });\n return isAccessTokenClaims(payload) ? payload : null;\n } catch {\n return null;\n }\n}\n","import { cache } from \"react\";\nimport { redirect } from \"next/navigation\";\nimport type { LemonSession, LemonUser } from \"../types.js\";\nimport { AUTH_SERVER_URL } from \"../constants.js\";\nimport { isApprovedClient } from \"../approval.js\";\nimport { verifyAccessToken } from \"./verify.js\";\n\nfunction claimsToUser(\n claims: NonNullable<Awaited<ReturnType<typeof verifyAccessToken>>>\n): LemonUser {\n return {\n uid: claims.sub,\n nickname: claims.nickname,\n profileImageUrl: claims.profile_image_url,\n role: claims.role,\n approvedClients: claims.approved_clients,\n };\n}\n\nexport const getUser = cache(async (): Promise<LemonUser | null> => {\n const claims = await verifyAccessToken();\n if (!claims) return null;\n return claimsToUser(claims);\n});\n\nexport interface GetSessionOptions {\n clientId?: string;\n}\n\nconst getSessionByClientId = cache(\n async (clientId?: string): Promise<LemonSession> => {\n const user = await getUser();\n if (!user) return { type: \"none\" };\n if (!isApprovedClient(user.approvedClients, clientId)) {\n return { type: \"unapproved\", user };\n }\n return { type: \"authenticated\", user };\n }\n);\n\nexport async function getSession(\n options: GetSessionOptions = {}\n): Promise<LemonSession> {\n return getSessionByClientId(options.clientId);\n}\n\nexport async function requireAuth(redirectTo = \"/\"): Promise<LemonUser> {\n const user = await getUser();\n if (user) return user;\n redirect(redirectTo);\n}\n\nexport interface RequireClientOptions {\n loginRedirectTo?: string;\n unapprovedRedirectTo?: string;\n}\n\nexport async function requireClient(\n clientId: string,\n optionsOrRedirectTo: string | RequireClientOptions = \"/\"\n): Promise<LemonUser> {\n const options =\n typeof optionsOrRedirectTo === \"string\"\n ? { loginRedirectTo: optionsOrRedirectTo }\n : optionsOrRedirectTo;\n const session = await getSession({ clientId });\n if (session.type === \"none\") {\n redirect(options.loginRedirectTo ?? \"/\");\n }\n if (session.type === \"unapproved\") {\n redirect(\n options.unapprovedRedirectTo ??\n `${AUTH_SERVER_URL}/error?code=FORBIDDEN`\n );\n }\n return session.user;\n}\n","import {\n DEVICE_ID_COOKIE,\n REFRESH_TOKEN_COOKIE,\n REFRESH_URL,\n} from \"../constants.js\";\nimport { isMockAuthEnabled } from \"../mock.js\";\n\nexport interface RefreshResult {\n ok: boolean;\n setCookieHeaders: string[];\n}\n\nexport async function refreshTokenFromCookie(\n refreshTokenCookie: string,\n deviceIdCookie?: string\n): Promise<RefreshResult> {\n if (isMockAuthEnabled()) {\n return { ok: true, setCookieHeaders: [] };\n }\n\n const res = await fetch(REFRESH_URL, {\n method: \"POST\",\n headers: {\n Cookie: buildRefreshCookieHeader(refreshTokenCookie, deviceIdCookie),\n },\n });\n\n if (!res.ok) {\n return { ok: false, setCookieHeaders: [] };\n }\n\n const setCookieHeaders = res.headers.getSetCookie();\n return { ok: true, setCookieHeaders };\n}\n\nfunction buildRefreshCookieHeader(\n refreshTokenCookie: string,\n deviceIdCookie?: string\n): string {\n const cookies = [`${REFRESH_TOKEN_COOKIE}=${refreshTokenCookie}`];\n if (deviceIdCookie) {\n cookies.push(`${DEVICE_ID_COOKIE}=${deviceIdCookie}`);\n }\n return cookies.join(\"; \");\n}\n"],"mappings":"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","names":["cookies"]}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { JWTPayload } from 'jose';
|
|
2
2
|
|
|
3
3
|
interface AccessTokenClaims extends JWTPayload {
|
|
4
|
+
token_type: "access";
|
|
4
5
|
sub: string;
|
|
5
6
|
nickname: string;
|
|
6
7
|
profile_image_url: string;
|
|
@@ -14,6 +15,15 @@ interface LemonUser {
|
|
|
14
15
|
role: "user" | "admin";
|
|
15
16
|
approvedClients: string[];
|
|
16
17
|
}
|
|
18
|
+
type LemonSession = {
|
|
19
|
+
type: "none";
|
|
20
|
+
} | {
|
|
21
|
+
type: "unapproved";
|
|
22
|
+
user: LemonUser;
|
|
23
|
+
} | {
|
|
24
|
+
type: "authenticated";
|
|
25
|
+
user: LemonUser;
|
|
26
|
+
};
|
|
17
27
|
interface UserProfile {
|
|
18
28
|
uid: string;
|
|
19
29
|
nickname: string;
|
|
@@ -21,4 +31,4 @@ interface UserProfile {
|
|
|
21
31
|
role: "user" | "admin";
|
|
22
32
|
}
|
|
23
33
|
|
|
24
|
-
export type { AccessTokenClaims as A,
|
|
34
|
+
export type { AccessTokenClaims as A, LemonSession as L, UserProfile as U, LemonUser as a };
|
package/package.json
CHANGED
package/dist/chunk-B2EFAFKY.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
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 = "access_token";
|
|
10
|
-
var REFRESH_TOKEN_COOKIE = "refresh_token";
|
|
11
|
-
|
|
12
|
-
export {
|
|
13
|
-
AUTH_SERVER_URL,
|
|
14
|
-
JWKS_URL,
|
|
15
|
-
LOGIN_URL,
|
|
16
|
-
REFRESH_URL,
|
|
17
|
-
LOGOUT_URL,
|
|
18
|
-
PROFILE_URL,
|
|
19
|
-
PROFILE_PAGE_URL,
|
|
20
|
-
ACCESS_TOKEN_COOKIE,
|
|
21
|
-
REFRESH_TOKEN_COOKIE
|
|
22
|
-
};
|
|
23
|
-
//# sourceMappingURL=chunk-B2EFAFKY.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/constants.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 = \"access_token\";\nexport const REFRESH_TOKEN_COOKIE = \"refresh_token\";\n"],"mappings":";AAAO,IAAM,kBAAkB;AACxB,IAAM,WAAW,GAAG,eAAe;AACnC,IAAM,YAAY,GAAG,eAAe;AACpC,IAAM,cAAc,GAAG,eAAe;AACtC,IAAM,aAAa,GAAG,eAAe;AACrC,IAAM,cAAc,GAAG,eAAe;AACtC,IAAM,mBAAmB,GAAG,eAAe;AAE3C,IAAM,sBAAsB;AAC5B,IAAM,uBAAuB;","names":[]}
|