@lemondouble/lemon-auth 0.10.0 → 0.11.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/LICENSE +21 -0
- package/README.md +75 -463
- package/dist/{chunk-ICPDIWYK.js → chunk-TKMZEMW7.js} +5 -11
- package/dist/chunk-TKMZEMW7.js.map +1 -0
- package/dist/client/index.d.ts +3 -27
- package/dist/client/index.js +1 -53
- package/dist/client/index.js.map +1 -1
- package/dist/proxy/index.js +225 -16
- package/dist/proxy/index.js.map +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/{url-qhLR3uNu.d.ts → url-O6F1o0px.d.ts} +1 -4
- package/package.json +1 -1
- package/dist/chunk-ICPDIWYK.js.map +0 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LemonDouble
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @lemondouble/lemon-auth
|
|
2
2
|
|
|
3
|
-
`*.lemondouble.com` 서비스를 위한 인증
|
|
3
|
+
`*.lemondouble.com` 서비스를 위한 인증 라이브러리입니다. Next.js 16+ 전용입니다.
|
|
4
4
|
|
|
5
5
|
`auth.lemondouble.com` 인증 서버와 연동하여 JWT 검증, 토큰 갱신, 로그인/로그아웃을 처리합니다.
|
|
6
6
|
|
|
@@ -10,454 +10,113 @@
|
|
|
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()`, `requireClient()`는 mock user를 로그인 유저처럼 반환합니다.
|
|
26
|
-
- `createAuthProxy()`는 모든 요청을 통과시킵니다. 승인 체크는 DAL에서 수행됩니다.
|
|
27
|
-
- `loginUrl(redirectUrl)`은 `redirectUrl`을 그대로 반환합니다.
|
|
28
|
-
- `profileUrl(redirectUrl?)`은 `redirectUrl ?? "/"`를 반환합니다.
|
|
29
|
-
- `refreshToken()`은 `true`, `logout()`은 no-op 후 `true`를 반환합니다.
|
|
30
|
-
|
|
31
13
|
## 엔트리포인트
|
|
32
14
|
|
|
33
15
|
| 경로 | 환경 | 용도 |
|
|
34
16
|
|------|------|------|
|
|
35
|
-
| `@lemondouble/lemon-auth/server` | Server Components, Route Handlers, Server Actions | 유저 조회,
|
|
36
|
-
| `@lemondouble/lemon-auth/client` | Client Components | AuthProvider,
|
|
37
|
-
| `@lemondouble/lemon-auth/proxy` | proxy.ts | 쿠키 기반 optimistic check |
|
|
17
|
+
| `@lemondouble/lemon-auth/server` | Server Components, Route Handlers, Server Actions | 유저 조회, 승인 체크, URL 헬퍼 |
|
|
18
|
+
| `@lemondouble/lemon-auth/client` | Client Components | AuthProvider, 세션 복구, 로그인/로그아웃 |
|
|
19
|
+
| `@lemondouble/lemon-auth/proxy` | `proxy.ts` | 쿠키 기반 optimistic check |
|
|
20
|
+
|
|
21
|
+
## 인증 흐름
|
|
22
|
+
|
|
23
|
+
- 로그인하면 auth 서버가 `.lemondouble.com` 도메인에 `lemon_access_token`(30분)과 `lemon_refresh_token`(30일) 쿠키를 설정합니다.
|
|
24
|
+
- 프록시는 쿠키의 `exp`만 보고 라우팅합니다. access token이 만료됐고 refresh token이 남아 있으면 복구 화면을 바로 응답합니다.
|
|
25
|
+
- 복구 화면이 브라우저에서 갱신한 뒤 같은 주소를 새로고침합니다. 화면에는 [lemon-design-system](https://github.com/LemonDouble/lemon-design-system)의 LD 캐릭터 일러스트 세 장 중 하나가 무작위로 나옵니다.
|
|
26
|
+
- auth 서버에 연결하지 못하면 실패 일러스트와 "다시 시도" 버튼을 보여줍니다. 401·403이면 세션이 끝난 것이므로 새로고침해서 로그인으로 보냅니다.
|
|
27
|
+
- 서명 검증과 승인 판단은 DAL(`getUser` / `getSession` / `requireClient`)이 합니다. 권한 판단은 반드시 DAL에서 합니다.
|
|
38
28
|
|
|
39
29
|
---
|
|
40
30
|
|
|
41
31
|
## Server — `@lemondouble/lemon-auth/server`
|
|
42
32
|
|
|
43
|
-
Server Components, Route Handlers, Server Actions에서 사용합니다.
|
|
44
|
-
|
|
45
33
|
### `getUser()`
|
|
46
34
|
|
|
47
|
-
쿠키의 `lemon_access_token`을 JWKS(ES256)로 검증하고 `LemonUser`
|
|
48
|
-
React `cache()`로 감싸져 있어서 **같은 요청 내에서 여러 번 호출해도 JWT 검증은 1회만** 실행됩니다.
|
|
49
|
-
|
|
50
|
-
```ts
|
|
51
|
-
import { getUser } from "@lemondouble/lemon-auth/server";
|
|
52
|
-
|
|
53
|
-
export default async function Page() {
|
|
54
|
-
const user = await getUser();
|
|
55
|
-
// → LemonUser | null
|
|
56
|
-
|
|
57
|
-
if (!user) return <p>로그인이 필요합니다</p>;
|
|
58
|
-
return <p>{user.nickname}님 환영합니다</p>;
|
|
59
|
-
}
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
반환되는 `LemonUser`:
|
|
35
|
+
쿠키의 `lemon_access_token`을 JWKS(ES256)로 검증하고 `LemonUser | null`을 반환합니다. React `cache()`로 감싸져 있어 같은 요청 안에서는 한 번만 검증합니다.
|
|
63
36
|
|
|
64
37
|
```ts
|
|
65
38
|
interface LemonUser {
|
|
66
|
-
uid: string;
|
|
67
|
-
nickname: string;
|
|
68
|
-
profileImageUrl: string;
|
|
69
|
-
role: "user" | "admin";
|
|
70
|
-
approvedClients: string[];
|
|
39
|
+
uid: string;
|
|
40
|
+
nickname: string;
|
|
41
|
+
profileImageUrl: string;
|
|
42
|
+
role: "user" | "admin";
|
|
43
|
+
approvedClients: string[];
|
|
71
44
|
}
|
|
72
45
|
```
|
|
73
46
|
|
|
74
47
|
### `getSession({ clientId })`
|
|
75
48
|
|
|
76
|
-
로그인 상태와 클라이언트 승인 상태를 함께 반환합니다. `clientId
|
|
49
|
+
로그인 상태와 클라이언트 승인 상태를 함께 반환합니다. `clientId`가 빈 값(환경변수 누락 등)이면 모든 유저가 `unapproved`가 됩니다. 승인 없이 로그인 여부만 필요하면 `getUser()`를 씁니다.
|
|
77
50
|
|
|
78
51
|
```ts
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const CLIENT_ID = process.env.CLIENT_ID!;
|
|
82
|
-
|
|
83
|
-
export default async function Page() {
|
|
84
|
-
const session = await getSession({ clientId: CLIENT_ID });
|
|
85
|
-
|
|
86
|
-
if (session.type === "none") return <p>로그인이 필요합니다</p>;
|
|
87
|
-
if (session.type === "unapproved") return <p>관리자 승인이 필요합니다</p>;
|
|
88
|
-
|
|
89
|
-
return <p>{session.user.nickname}님 환영합니다</p>;
|
|
90
|
-
}
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
반환되는 `LemonSession`:
|
|
94
|
-
|
|
95
|
-
```ts
|
|
96
|
-
type LemonSession =
|
|
97
|
-
| { type: "none" }
|
|
98
|
-
| { type: "unapproved"; user: LemonUser }
|
|
99
|
-
| { type: "authenticated"; user: LemonUser };
|
|
52
|
+
const session = await getSession({ clientId: process.env.CLIENT_ID! });
|
|
53
|
+
// { type: "none" } | { type: "unapproved"; user } | { type: "authenticated"; user }
|
|
100
54
|
```
|
|
101
55
|
|
|
102
56
|
### `requireClient(clientId, options?)`
|
|
103
57
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
```ts
|
|
107
|
-
import { requireClient } from "@lemondouble/lemon-auth/server";
|
|
108
|
-
|
|
109
|
-
const CLIENT_ID = process.env.CLIENT_ID!;
|
|
110
|
-
|
|
111
|
-
export default async function Page() {
|
|
112
|
-
const user = await requireClient(CLIENT_ID, {
|
|
113
|
-
loginRedirectTo: "/",
|
|
114
|
-
unapprovedRedirectTo: "/pending-approval",
|
|
115
|
-
});
|
|
116
|
-
return <p>{user.nickname}</p>;
|
|
117
|
-
}
|
|
118
|
-
```
|
|
58
|
+
승인된 유저만 반환하고, 아니면 redirect합니다.
|
|
119
59
|
|
|
120
|
-
|
|
|
121
|
-
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
124
|
-
| `options.unapprovedRedirectTo` | `string` | auth-server `/error?code=FORBIDDEN` | 로그인은 됐지만 클라이언트 미승인 시 redirect 경로 |
|
|
60
|
+
| 옵션 | 기본값 | 설명 |
|
|
61
|
+
|------|--------|------|
|
|
62
|
+
| `loginRedirectTo` | `"/"` | 미로그인 시 redirect 경로 |
|
|
63
|
+
| `unapprovedRedirectTo` | auth 서버 `/error?code=FORBIDDEN` | 미승인 시 redirect 경로 |
|
|
125
64
|
|
|
126
65
|
### `loginUrl()` / `profileUrl()`
|
|
127
66
|
|
|
128
|
-
Client 엔트리포인트의 같은 함수를 Server Component
|
|
129
|
-
|
|
130
|
-
```ts
|
|
131
|
-
import { loginUrl, profileUrl } from "@lemondouble/lemon-auth/server";
|
|
132
|
-
```
|
|
67
|
+
Client 엔트리포인트의 같은 함수를 Server Component에서 쓸 수 있게 re-export합니다.
|
|
133
68
|
|
|
134
69
|
---
|
|
135
70
|
|
|
136
71
|
## Proxy — `@lemondouble/lemon-auth/proxy`
|
|
137
72
|
|
|
138
|
-
Next.js 16의 `proxy.ts`에서 사용합니다. **쿠키만 읽는 optimistic check** 만 합니다.
|
|
139
|
-
|
|
140
|
-
> 프록시는 토큰을 갱신하지 않고 JWT 서명도 검증하지 않습니다. 갱신은
|
|
141
|
-
> `<SessionRestore />`(브라우저)가 유일하게 맡고, 진짜 검증은
|
|
142
|
-
> DAL(`getSession` / `requireClient`)이 합니다. 마이그레이션은
|
|
143
|
-
> [0.8.x → 0.9.0](#08x--090-마이그레이션) 참고.
|
|
144
|
-
|
|
145
|
-
Next.js 공식 인증 가이드의 권장 구조를 그대로 따릅니다.
|
|
146
|
-
|
|
147
|
-
> since Proxy runs on every route, including prefetched routes, it's important to
|
|
148
|
-
> only read the session from the cookie (optimistic checks), and avoid database
|
|
149
|
-
> checks to prevent performance issues.
|
|
150
|
-
>
|
|
151
|
-
> — [Next.js — Authentication](https://nextjs.org/docs/app/guides/authentication)
|
|
152
|
-
|
|
153
73
|
### `createAuthProxy(options?)`
|
|
154
74
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
publicPaths: ["/", "/about", "/api/public/*"],
|
|
161
|
-
loginRedirectUrl: (request) => request.url,
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
export const config = {
|
|
165
|
-
matcher: [
|
|
166
|
-
"/((?!_next/static|_next/image|sw.js|manifest.webmanifest|favicon.ico|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico|woff|woff2|ttf|eot|otf)$).*)",
|
|
167
|
-
],
|
|
168
|
-
};
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
#### `AuthProxyOptions`
|
|
172
|
-
|
|
173
|
-
| 옵션 | 타입 | 기본값 | 설명 |
|
|
174
|
-
|------|------|--------|------|
|
|
175
|
-
| `publicPaths` | `string[]` | `[]` | 로그인 없이 열어둘 경로. 익명이면 통과, 만료 세션이면 복구를 거친다. `*`로 끝나면 prefix 매칭 (예: `"/api/public/*"`) |
|
|
176
|
-
| `apiPaths` | `string[]` | `["/api/*"]` | 보호 API 경로. 인증 실패 시 redirect 대신 401 JSON을 반환한다. disable하려면 `[]` |
|
|
177
|
-
| `loginRedirectUrl` | `string \| (request: NextRequest) => string` | `undefined` | 미인증 시 Google 로그인 후 돌아올 URL. deep link 보존이 필요하면 `(request) => request.url` 사용. 미설정 시 `"/"` 로 redirect |
|
|
178
|
-
|
|
179
|
-
#### 동작 방식
|
|
180
|
-
|
|
181
|
-
`<SessionRestore />`는 반드시 `/auth/restore`에 마운트합니다.
|
|
75
|
+
| 옵션 | 기본값 | 설명 |
|
|
76
|
+
|------|--------|------|
|
|
77
|
+
| `publicPaths` | `[]` | 로그인 없이 열어둘 경로. `*`로 끝나면 prefix 매칭 |
|
|
78
|
+
| `apiPaths` | `["/api/*"]` | 인증 실패 시 redirect 대신 `401 { "code": "UNAUTHORIZED" }`를 반환할 경로 |
|
|
79
|
+
| `loginRedirectUrl` | `undefined` | 로그인 후 돌아올 URL. 문자열 또는 `(request) => string`. 미설정 시 `"/"`로 redirect |
|
|
182
80
|
|
|
183
81
|
요청은 위에서부터 순서대로 판정됩니다.
|
|
184
82
|
|
|
185
|
-
1.
|
|
186
|
-
2.
|
|
187
|
-
3. `
|
|
188
|
-
4.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
6. `lemon_refresh_token` 있음 → `/auth/restore?next=<원래 경로>` 로 redirect.
|
|
193
|
-
**공개 경로도 포함입니다**
|
|
194
|
-
7. 공개 경로 → 통과 (익명 방문자)
|
|
195
|
-
8. 나머지 → `loginRedirectUrl` 을 담아 로그인 페이지로 redirect
|
|
196
|
-
|
|
197
|
-
공개 경로도 만료 세션이면 복구를 거치는 이유: 그냥 통과시키면 "로그아웃
|
|
198
|
-
상태로 렌더됐다가 갱신 후 뒤집히는" 화면이 되고, 그걸 가리려는 우회 코드가
|
|
199
|
-
앱마다 생깁니다. 복구를 거치면 처음부터 로그인 상태로 렌더됩니다.
|
|
200
|
-
|
|
201
|
-
**프리페치 요청**: 다른 요청과 똑같이 처리합니다. 통과시키면 만료된 토큰으로
|
|
202
|
-
페이지가 렌더되고 거기서 나온 redirect 가 라우터 캐시에 굳어, 어느 링크를 눌러도
|
|
203
|
-
같은 곳으로 튀게 됩니다. 반면 프록시가 돌려주는 복구 경로 리다이렉트는 굳어도
|
|
204
|
-
해가 없습니다 — 복구 경로는 막다른 길이 아니라 세션을 되살리고 `next` 로
|
|
205
|
-
되돌려 보내므로 목적지도 보존됩니다.
|
|
206
|
-
|
|
207
|
-
`exp` 만 보고 **서명은 검증하지 않습니다.** 위조 토큰으로 이 검사는 통과할 수
|
|
208
|
-
있으나 DAL 에서 걸립니다. 프록시의 판단은 "어느 화면으로 보낼까" 이지
|
|
209
|
-
"권한이 있는가" 가 아닙니다. 권한 판단은 반드시 DAL 에서 하십시오.
|
|
210
|
-
|
|
211
|
-
`loginRedirectUrl`은 고정 URL 문자열과 요청별 resolver 함수를 모두 지원합니다.
|
|
212
|
-
|
|
213
|
-
```ts
|
|
214
|
-
createAuthProxy({
|
|
215
|
-
// 로그인 후 항상 같은 화면으로 복귀
|
|
216
|
-
loginRedirectUrl: "https://myapp.lemondouble.com/dashboard",
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
createAuthProxy({
|
|
220
|
-
// 로그인 후 사용자가 원래 요청한 path/query로 복귀
|
|
221
|
-
loginRedirectUrl: (request) => request.url,
|
|
222
|
-
});
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
### 0.9.x → 0.10.0 마이그레이션
|
|
226
|
-
|
|
227
|
-
쓰이지 않던 API를 정리했습니다.
|
|
228
|
-
|
|
229
|
-
- 제거: `requireAuth`, `verifyAccessToken`, `verifyAccessTokenString`, `/server`의 URL·쿠키 상수, `AccessTokenClaims`·`UserProfile` 타입
|
|
230
|
-
- 제거: proxy의 `bypassPaths`·`restorePath` 옵션, `DEFAULT_AUTH_BYPASS_PATHS`·`DEFAULT_RESTORE_PATH`·`PROXY_AUTH_ERROR`
|
|
231
|
-
- `getSession()`의 `clientId`가 필수가 됐습니다. 빈 값이면 승인 검사를 건너뛰던(fail-open) 동작이 `unapproved`로 바뀝니다.
|
|
232
|
-
- `requireClient()`의 두 번째 인자는 옵션 객체만 받습니다.
|
|
233
|
-
- `LOGIN_URL`·`PROFILE_PAGE_URL`로 URL을 직접 만들던 곳은 `/server`에서 `loginUrl()`·`profileUrl()`을 import해 씁니다.
|
|
234
|
-
|
|
235
|
-
### 0.8.x → 0.9.0 마이그레이션
|
|
236
|
-
|
|
237
|
-
갱신 주체가 `<SessionRestore />` 하나로 통일되고, 실패 처리가 서버로
|
|
238
|
-
넘어갔습니다. **auth-server v2026.08.03.2 이상이 먼저 배포되어 있어야 합니다**
|
|
239
|
-
— 죽은 토큰의 401 응답이 세션 쿠키를 지워주는 동작에 의존합니다.
|
|
240
|
-
|
|
241
|
-
**1. `<AutoTokenRefresh />` 제거** — export 자체가 사라졌습니다. 루트
|
|
242
|
-
레이아웃에서 마운트와 import 를 지웁니다. 공개 경로의 만료 세션은 이제
|
|
243
|
-
프록시가 복구 경로로 보내 처리합니다.
|
|
244
|
-
|
|
245
|
-
**2. `publicPaths` 에서 복구 경로 제거** — `/auth/restore` 는 프록시가 자동으로
|
|
246
|
-
통과시킵니다. 남겨둬도 해는 없지만 필요 없습니다.
|
|
247
|
-
|
|
248
|
-
**3. `refreshTokenFromCookie` 제거** — 서버사이드 갱신 진입점이 사라졌습니다.
|
|
249
|
-
갱신은 브라우저(`<SessionRestore />`)만 합니다.
|
|
83
|
+
1. PWA·정적 리소스(`/sw.js`, `/manifest.webmanifest`, `/icons/*` 등) → 통과
|
|
84
|
+
2. access token 만료까지 60초 이상 → 통과
|
|
85
|
+
3. `apiPaths` → 공개 경로면 통과, 아니면 401
|
|
86
|
+
4. 비-GET 요청(서버 액션 등) → 통과 (DAL이 거부)
|
|
87
|
+
5. refresh token 있음 → 복구 화면 응답 (공개 경로 포함)
|
|
88
|
+
6. 공개 경로 → 통과
|
|
89
|
+
7. 나머지 → 로그인 페이지
|
|
250
90
|
|
|
251
|
-
|
|
252
|
-
돌아갑니다. 죽은 토큰의 쿠키는 서버가 지워주므로, 이후 판단(로그인/익명)은
|
|
253
|
-
프록시와 DAL 이 합니다.
|
|
91
|
+
앱 안에서 링크로 이동할 때(소프트 내비게이션)도 복구 화면을 받으면, Next 라우터가 같은 주소로 전체 페이지 이동을 해서 복구가 이어집니다.
|
|
254
92
|
|
|
255
|
-
|
|
256
|
-
복구를 거친 뒤 처음부터 로그인 상태로 렌더되므로, refresh 쿠키 유무를 보고
|
|
257
|
-
스피너를 띄우던 코드는 필요 없습니다.
|
|
93
|
+
### 0.10.x → 0.11.0 마이그레이션
|
|
258
94
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
보호 경로 중 `apiPaths`(기본 `/api/*`)에 매칭되는 요청은 인증 실패 시 redirect 대신 JSON 응답을 반환합니다. fetch 호출이 OAuth HTML로 리다이렉트되어 계약이 깨지는 문제를 방지합니다.
|
|
262
|
-
|
|
263
|
-
| 상황 | 응답 |
|
|
264
|
-
|------|------|
|
|
265
|
-
| 미인증 (access/refresh 모두 실패) | `401 { "code": "UNAUTHORIZED" }` |
|
|
266
|
-
|
|
267
|
-
`/api/*` 외의 경로(예: tRPC, GraphQL)도 JSON 응답을 받게 하려면 `apiPaths`에 추가합니다.
|
|
268
|
-
|
|
269
|
-
```ts
|
|
270
|
-
import { DEFAULT_API_PATHS } from "@lemondouble/lemon-auth/proxy";
|
|
271
|
-
|
|
272
|
-
createAuthProxy({
|
|
273
|
-
apiPaths: [...DEFAULT_API_PATHS, "/trpc/*"],
|
|
274
|
-
});
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
`apiPaths: []`로 두면 모든 보호 경로가 redirect로 동작합니다 (이전 버전 동작).
|
|
95
|
+
- `<SessionRestore>`를 제거했습니다. 프록시가 복구 화면을 직접 응답하므로 `app/auth/restore/page.tsx`를 삭제합니다.
|
|
278
96
|
|
|
279
97
|
---
|
|
280
98
|
|
|
281
99
|
## Client — `@lemondouble/lemon-auth/client`
|
|
282
100
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
// app/layout.tsx (Server Component)
|
|
291
|
-
import { getUser } from "@lemondouble/lemon-auth/server";
|
|
292
|
-
import { AuthProvider } from "@lemondouble/lemon-auth/client";
|
|
293
|
-
|
|
294
|
-
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
295
|
-
const user = await getUser();
|
|
296
|
-
|
|
297
|
-
return (
|
|
298
|
-
<html lang="ko">
|
|
299
|
-
<body>
|
|
300
|
-
<AuthProvider user={user}>
|
|
301
|
-
{children}
|
|
302
|
-
</AuthProvider>
|
|
303
|
-
</body>
|
|
304
|
-
</html>
|
|
305
|
-
);
|
|
306
|
-
}
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
```tsx
|
|
310
|
-
// components/nav.tsx (Client Component)
|
|
311
|
-
"use client";
|
|
312
|
-
import { useAuth } from "@lemondouble/lemon-auth/client";
|
|
313
|
-
|
|
314
|
-
export function Nav() {
|
|
315
|
-
const { user, isAuthenticated } = useAuth();
|
|
316
|
-
// user: LemonUser | null
|
|
317
|
-
// isAuthenticated: boolean
|
|
318
|
-
|
|
319
|
-
if (!isAuthenticated) return <a href="/login">로그인</a>;
|
|
320
|
-
return <span>{user!.nickname}</span>;
|
|
321
|
-
}
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
### `<SessionRestore>`
|
|
325
|
-
|
|
326
|
-
**토큰을 갱신하는 유일한 주체입니다.** 프록시가 만료된 세션을 이리로 보내고,
|
|
327
|
-
갱신이 끝나면 `next` 파라미터가 가리키는 곳으로 **하드 네비게이션**
|
|
328
|
-
(`window.location.replace`) 합니다. 소프트 네비는 루트 레이아웃과
|
|
329
|
-
`AuthProvider` 를 다시 렌더하지 않아, 옛 세션 스냅샷이 남은 채 화면이
|
|
330
|
-
어긋나기 때문입니다.
|
|
331
|
-
|
|
332
|
-
```tsx
|
|
333
|
-
// app/auth/restore/page.tsx
|
|
334
|
-
import { Suspense } from "react";
|
|
335
|
-
import { SessionRestore } from "@lemondouble/lemon-auth/client";
|
|
336
|
-
|
|
337
|
-
export const dynamic = "force-dynamic";
|
|
338
|
-
|
|
339
|
-
export default function RestorePage() {
|
|
340
|
-
return (
|
|
341
|
-
<Suspense fallback={null}>
|
|
342
|
-
<SessionRestore fallback={<p>세션 복원 중...</p>} />
|
|
343
|
-
</Suspense>
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
```
|
|
347
|
-
|
|
348
|
-
| prop | 타입 | 필수 | 설명 |
|
|
349
|
-
|------|------|------|------|
|
|
350
|
-
| `fallback` | `React.ReactNode` | **필수** | 갱신하는 동안 렌더할 UI |
|
|
351
|
-
|
|
352
|
-
- 갱신 실패도 `next` 로 돌아갑니다. 죽은 토큰이면 서버가 401 응답에서 세션
|
|
353
|
-
쿠키를 지워 주므로, 이후 판단(로그인으로 보낼지, 익명으로 보여줄지)은
|
|
354
|
-
프록시와 DAL 이 합니다.
|
|
355
|
-
- 서버에 연결하지 못하면(5xx · 네트워크 오류) 로그인으로 보내지 않고
|
|
356
|
-
"다시 시도" 버튼을 보여줍니다. 토큰이 살아 있을 수 있기 때문입니다.
|
|
357
|
-
- 이 경로는 프록시가 자동으로 통과시키므로 `publicPaths` 에 넣지 않아도
|
|
358
|
-
됩니다.
|
|
359
|
-
- `useSearchParams()` 를 쓰므로 `<Suspense>` 로 감싸야 빌드가 통과합니다.
|
|
360
|
-
- `next` 는 같은 출처의 경로만 허용합니다. 열린 리다이렉트가 되지 않도록
|
|
361
|
-
`//evil.com` 같은 값은 `/` 로 떨어뜨립니다.
|
|
362
|
-
|
|
363
|
-
### `loginUrl(redirectUrl)`
|
|
364
|
-
|
|
365
|
-
Google 로그인 URL을 생성합니다. `<a>` 태그나 `window.location.href`에 사용합니다.
|
|
366
|
-
|
|
367
|
-
```ts
|
|
368
|
-
import { loginUrl } from "@lemondouble/lemon-auth/client";
|
|
369
|
-
|
|
370
|
-
loginUrl("https://myapp.lemondouble.com/dashboard")
|
|
371
|
-
// → "https://auth.lemondouble.com/api/oauth2/google/login?redirect_url=https%3A%2F%2Fmyapp.lemondouble.com%2Fdashboard"
|
|
372
|
-
```
|
|
373
|
-
|
|
374
|
-
| 파라미터 | 타입 | 설명 |
|
|
375
|
-
|---------|------|------|
|
|
376
|
-
| `redirectUrl` | `string` | 로그인 완료 후 돌아올 절대 URL (`https://` + `lemondouble.com` 또는 `*.lemondouble.com`만 허용) |
|
|
377
|
-
|
|
378
|
-
허용되지 않는 URL이면 `Error`를 throw합니다. Mock User가 활성화된 로컬 개발 환경에서는 검증하지 않고 `redirectUrl`을 그대로 반환합니다.
|
|
379
|
-
|
|
380
|
-
### `logout()`
|
|
381
|
-
|
|
382
|
-
로그아웃을 수행합니다. `lemon_refresh_token`을 폐기하고 쿠키를 삭제합니다.
|
|
383
|
-
서버 응답이 성공이면 `true`, 실패 응답 또는 네트워크 예외면 `false`를 반환합니다.
|
|
384
|
-
반환값을 무시하고 기존처럼 `await logout()`만 호출해도 됩니다.
|
|
385
|
-
|
|
386
|
-
```tsx
|
|
387
|
-
"use client";
|
|
388
|
-
import { logout } from "@lemondouble/lemon-auth/client";
|
|
389
|
-
|
|
390
|
-
function LogoutButton() {
|
|
391
|
-
const handleLogout = async () => {
|
|
392
|
-
const ok = await logout();
|
|
393
|
-
if (!ok) {
|
|
394
|
-
// 필요하면 실패 안내를 표시
|
|
395
|
-
}
|
|
396
|
-
window.location.href = "/";
|
|
397
|
-
};
|
|
398
|
-
|
|
399
|
-
return <button onClick={handleLogout}>로그아웃</button>;
|
|
400
|
-
}
|
|
401
|
-
```
|
|
402
|
-
|
|
403
|
-
### `profileUrl(redirectUrl?)`
|
|
404
|
-
|
|
405
|
-
프로필 편집 페이지 URL을 생성합니다. 닉네임/프로필 이미지 변경 페이지로 이동시킬 때 사용합니다.
|
|
406
|
-
|
|
407
|
-
```ts
|
|
408
|
-
import { profileUrl } from "@lemondouble/lemon-auth/client";
|
|
409
|
-
|
|
410
|
-
profileUrl()
|
|
411
|
-
// → "https://auth.lemondouble.com/profile"
|
|
412
|
-
|
|
413
|
-
profileUrl("https://myapp.lemondouble.com/settings")
|
|
414
|
-
// → "https://auth.lemondouble.com/profile?redirect_url=https%3A%2F%2Fmyapp.lemondouble.com%2Fsettings"
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
| 파라미터 | 타입 | 기본값 | 설명 |
|
|
418
|
-
|---------|------|--------|------|
|
|
419
|
-
| `redirectUrl` | `string` | `undefined` | 편집 완료 후 돌아올 URL. 설정하면 "← 돌아가기" 버튼이 표시됨 |
|
|
420
|
-
|
|
421
|
-
### `refreshToken()`
|
|
422
|
-
|
|
423
|
-
클라이언트에서 토큰 갱신을 수행합니다. 갱신은 보통 `<SessionRestore />` 가
|
|
424
|
-
맡으므로 직접 호출할 일은 적습니다. (승인 완료 후 새 `approved_clients` 를
|
|
425
|
-
즉시 반영하고 싶을 때 정도)
|
|
426
|
-
|
|
427
|
-
```ts
|
|
428
|
-
import { refreshToken } from "@lemondouble/lemon-auth/client";
|
|
429
|
-
|
|
430
|
-
const ok = await refreshToken();
|
|
431
|
-
// → boolean (갱신 성공 여부)
|
|
432
|
-
```
|
|
101
|
+
| API | 설명 |
|
|
102
|
+
|-----|------|
|
|
103
|
+
| `<AuthProvider user>` / `useAuth()` | Server Component에서 받은 `user`를 Client Component에 전달합니다. `useAuth()`는 `{ user, isAuthenticated }`를 반환합니다 |
|
|
104
|
+
| `loginUrl(redirectUrl)` | Google 로그인 URL을 만듭니다. `redirectUrl`은 `https://` + `*.lemondouble.com`만 허용하며, 아니면 throw합니다 |
|
|
105
|
+
| `profileUrl(redirectUrl?)` | auth 서버 프로필 편집 페이지 URL을 만듭니다 |
|
|
106
|
+
| `logout()` | refresh token을 폐기하고 쿠키를 지웁니다. 성공 여부를 `boolean`으로 반환합니다 |
|
|
107
|
+
| `refreshToken()` | 즉시 갱신합니다. 승인 직후 새 `approved_clients`를 반영할 때 씁니다 |
|
|
433
108
|
|
|
434
109
|
---
|
|
435
110
|
|
|
436
|
-
##
|
|
437
|
-
|
|
438
|
-
```ts
|
|
439
|
-
import type { LemonSession, LemonUser } from "@lemondouble/lemon-auth/server";
|
|
440
|
-
import type { LemonUser } from "@lemondouble/lemon-auth/client";
|
|
441
|
-
```
|
|
111
|
+
## 연동 예시
|
|
442
112
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
## 전체 연동 예시
|
|
446
|
-
|
|
447
|
-
새 Next.js 16 프로젝트에 인증을 추가하는 권장 예시입니다.
|
|
448
|
-
|
|
449
|
-
### 1. proxy.ts — optimistic check
|
|
113
|
+
### `proxy.ts`
|
|
450
114
|
|
|
451
115
|
```ts
|
|
452
116
|
import { createAuthProxy } from "@lemondouble/lemon-auth/proxy";
|
|
453
117
|
|
|
454
118
|
export default createAuthProxy({
|
|
455
|
-
publicPaths: [
|
|
456
|
-
"/",
|
|
457
|
-
"/login",
|
|
458
|
-
"/pending-approval",
|
|
459
|
-
"/api/public/*",
|
|
460
|
-
],
|
|
119
|
+
publicPaths: ["/", "/pending-approval", "/api/public/*"],
|
|
461
120
|
loginRedirectUrl: (request) => request.url,
|
|
462
121
|
});
|
|
463
122
|
|
|
@@ -468,46 +127,7 @@ export const config = {
|
|
|
468
127
|
};
|
|
469
128
|
```
|
|
470
129
|
|
|
471
|
-
`
|
|
472
|
-
`/api/*` 보호 API는 인증 실패 시 redirect 대신 `401` JSON 응답을 반환합니다.
|
|
473
|
-
복구 경로(`/auth/restore`)는 프록시가 자동으로 통과시키므로 `publicPaths` 에 넣지 않습니다.
|
|
474
|
-
|
|
475
|
-
사용자 DB 동기화 같은 작업은 **프록시가 아니라 DAL 에서** 하십시오. 프록시는
|
|
476
|
-
프리페치를 포함해 모든 요청에서 돌기 때문에, 그 자리에서 DB 를 건드리면 화면
|
|
477
|
-
하나 여는 데 쿼리가 여러 번 나갑니다.
|
|
478
|
-
|
|
479
|
-
```ts
|
|
480
|
-
// lib/dal.ts
|
|
481
|
-
import { cache } from "react";
|
|
482
|
-
import { getUser } from "@lemondouble/lemon-auth/server";
|
|
483
|
-
import { upsertUser } from "@/lib/db";
|
|
484
|
-
|
|
485
|
-
export const getCurrentUser = cache(async () => {
|
|
486
|
-
const user = await getUser();
|
|
487
|
-
if (!user) return null;
|
|
488
|
-
await upsertUser(user); // React cache() 덕에 요청당 한 번만 돈다
|
|
489
|
-
return user;
|
|
490
|
-
});
|
|
491
|
-
```
|
|
492
|
-
|
|
493
|
-
### 2. app/auth/restore/page.tsx — 세션 복구 착지점
|
|
494
|
-
|
|
495
|
-
```tsx
|
|
496
|
-
import { Suspense } from "react";
|
|
497
|
-
import { SessionRestore } from "@lemondouble/lemon-auth/client";
|
|
498
|
-
|
|
499
|
-
export const dynamic = "force-dynamic";
|
|
500
|
-
|
|
501
|
-
export default function RestorePage() {
|
|
502
|
-
return (
|
|
503
|
-
<Suspense fallback={null}>
|
|
504
|
-
<SessionRestore fallback={<p>세션 복원 중...</p>} />
|
|
505
|
-
</Suspense>
|
|
506
|
-
);
|
|
507
|
-
}
|
|
508
|
-
```
|
|
509
|
-
|
|
510
|
-
### 3. app/layout.tsx — AuthProvider
|
|
130
|
+
### `app/layout.tsx`
|
|
511
131
|
|
|
512
132
|
```tsx
|
|
513
133
|
import { getUser } from "@lemondouble/lemon-auth/server";
|
|
@@ -525,35 +145,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|
|
525
145
|
}
|
|
526
146
|
```
|
|
527
147
|
|
|
528
|
-
레이아웃은 `user
|
|
529
|
-
다시 렌더되지 않으므로, 여기서 인증을 판단하면 낡은 스냅샷으로 판단하게
|
|
530
|
-
됩니다. 게이트는 페이지의 DAL 호출이 맡습니다.
|
|
531
|
-
|
|
532
|
-
### 4. app/page.tsx — 공개 페이지 (로그인 버튼)
|
|
533
|
-
|
|
534
|
-
```tsx
|
|
535
|
-
import { getUser } from "@lemondouble/lemon-auth/server";
|
|
536
|
-
import { redirect } from "next/navigation";
|
|
537
|
-
import { LoginButton } from "@/components/login-button";
|
|
538
|
-
|
|
539
|
-
export default async function Home() {
|
|
540
|
-
const user = await getUser();
|
|
541
|
-
if (user) redirect("/dashboard");
|
|
542
|
-
return <LoginButton />;
|
|
543
|
-
}
|
|
544
|
-
```
|
|
545
|
-
|
|
546
|
-
```tsx
|
|
547
|
-
// components/login-button.tsx
|
|
548
|
-
"use client";
|
|
549
|
-
import { loginUrl } from "@lemondouble/lemon-auth/client";
|
|
148
|
+
레이아웃은 소프트 네비게이션에서 다시 렌더되지 않으므로 `user`를 표시용으로만 씁니다. 접근 제어는 페이지에서 합니다.
|
|
550
149
|
|
|
551
|
-
|
|
552
|
-
return <a href={loginUrl("https://myapp.lemondouble.com/dashboard")}>Google로 로그인</a>;
|
|
553
|
-
}
|
|
554
|
-
```
|
|
555
|
-
|
|
556
|
-
### 5. app/dashboard/page.tsx — 보호 페이지
|
|
150
|
+
### 보호 페이지
|
|
557
151
|
|
|
558
152
|
```tsx
|
|
559
153
|
import { requireClient } from "@lemondouble/lemon-auth/server";
|
|
@@ -566,11 +160,15 @@ export default async function Dashboard() {
|
|
|
566
160
|
}
|
|
567
161
|
```
|
|
568
162
|
|
|
569
|
-
###
|
|
163
|
+
### 로그인 · 로그아웃 버튼
|
|
570
164
|
|
|
571
165
|
```tsx
|
|
572
166
|
"use client";
|
|
573
|
-
import { logout } from "@lemondouble/lemon-auth/client";
|
|
167
|
+
import { loginUrl, logout } from "@lemondouble/lemon-auth/client";
|
|
168
|
+
|
|
169
|
+
export function LoginButton() {
|
|
170
|
+
return <a href={loginUrl("https://myapp.lemondouble.com/dashboard")}>Google로 로그인</a>;
|
|
171
|
+
}
|
|
574
172
|
|
|
575
173
|
export function LogoutButton() {
|
|
576
174
|
return (
|
|
@@ -585,3 +183,17 @@ export function LogoutButton() {
|
|
|
585
183
|
);
|
|
586
184
|
}
|
|
587
185
|
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## 로컬 개발 Mock User
|
|
190
|
+
|
|
191
|
+
auth 서버 없이 로그인 상태를 재현하려면 `.env.local`에 설정합니다. `NODE_ENV === "production"`에서는 무시됩니다.
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
NEXT_PUBLIC_LEMON_AUTH_MOCK_USER='{"uid":"local-user","nickname":"Local User","profileImageUrl":"","role":"admin","approvedClients":["*"]}'
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- 서버 API는 mock user를 로그인 유저로 반환합니다. `approvedClients`의 `"*"`는 모든 `clientId`를 승인합니다.
|
|
198
|
+
- 프록시는 모든 요청을 통과시킵니다.
|
|
199
|
+
- `loginUrl()`은 인자를, `profileUrl()`은 인자(없으면 `"/"`)를 그대로 반환하고, `logout()`·`refreshToken()`은 no-op 후 `true`를 반환합니다.
|