@lemondouble/lemon-auth 0.9.1 → 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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @lemondouble/lemon-auth
2
2
 
3
- `*.lemondouble.com` 서비스를 위한 인증 라이브러리. Next.js 16+ 전용.
3
+ `*.lemondouble.com` 서비스를 위한 인증 라이브러리입니다. Next.js 16+ 전용입니다.
4
4
 
5
5
  `auth.lemondouble.com` 인증 서버와 연동하여 JWT 검증, 토큰 갱신, 로그인/로그아웃을 처리합니다.
6
6
 
@@ -10,574 +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()`, `requireAuth()`, `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 | JWT 검증, 유저 조회, 인증 체크 |
36
- | `@lemondouble/lemon-auth/client` | Client Components | AuthProvider, useAuth 훅, URL 헬퍼 |
37
- | `@lemondouble/lemon-auth/proxy` | proxy.ts | 쿠키 기반 optimistic check |
38
-
39
- ---
40
-
41
- ## Server — `@lemondouble/lemon-auth/server`
42
-
43
- Server Components, Route Handlers, Server Actions에서 사용합니다.
44
-
45
- ### `verifyAccessToken()`
46
-
47
- 쿠키에서 `lemon_access_token`을 읽어 JWKS(ES256)로 검증합니다.
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 |
48
20
 
49
- ```ts
50
- import { verifyAccessToken } from "@lemondouble/lemon-auth/server";
51
-
52
- const claims = await verifyAccessToken();
53
- // → AccessTokenClaims | null
54
- ```
55
-
56
- 반환되는 `AccessTokenClaims`:
57
-
58
- ```ts
59
- interface AccessTokenClaims {
60
- token_type: "access"; // access token 여부
61
- sub: string; // 사용자 UUID
62
- nickname: string; // 닉네임
63
- profile_image_url: string; // 프로필 이미지 URL
64
- role: "user" | "admin"; // 역할
65
- approved_clients: string[]; // 승인된 클라이언트 ID 목록
66
- iss: string; // issuer (https://auth.lemondouble.com)
67
- exp: number; // 만료 시간
68
- iat: number; // 발급 시간
69
- }
70
- ```
71
-
72
- ### `verifyAccessTokenString(token)`
21
+ ## 인증 흐름
73
22
 
74
- 토큰 문자열을 직접 전달하여 검증합니다. 쿠키 대신 `Authorization` 헤더 등에서 토큰을 꺼낼 때 사용합니다.
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에서 합니다.
75
28
 
76
- ```ts
77
- import { verifyAccessTokenString } from "@lemondouble/lemon-auth/server";
78
-
79
- // Route Handler에서 Authorization 헤더 사용 예시
80
- export async function GET(request: Request) {
81
- const token = request.headers.get("Authorization")?.replace("Bearer ", "");
82
- if (!token) return new Response(null, { status: 401 });
83
-
84
- const claims = await verifyAccessTokenString(token);
85
- if (!claims) return new Response(null, { status: 401 });
29
+ ---
86
30
 
87
- return Response.json({ userId: claims.sub });
88
- }
89
- ```
31
+ ## Server — `@lemondouble/lemon-auth/server`
90
32
 
91
33
  ### `getUser()`
92
34
 
93
- `verifyAccessToken()`을 호출하고 결과를 `LemonUser` 객체로 변환합니다.
94
- React `cache()`로 감싸져 있어서 **같은 요청 내에서 여러 번 호출해도 JWT 검증은 1회만** 실행됩니다.
95
-
96
- ```ts
97
- import { getUser } from "@lemondouble/lemon-auth/server";
98
-
99
- export default async function Page() {
100
- const user = await getUser();
101
- // → LemonUser | null
102
-
103
- if (!user) return <p>로그인이 필요합니다</p>;
104
- return <p>{user.nickname}님 환영합니다</p>;
105
- }
106
- ```
107
-
108
- 반환되는 `LemonUser`:
35
+ 쿠키의 `lemon_access_token`을 JWKS(ES256)로 검증하고 `LemonUser | null`을 반환합니다. React `cache()`로 감싸져 있어 같은 요청 안에서는 한 번만 검증합니다.
109
36
 
110
37
  ```ts
111
38
  interface LemonUser {
112
- uid: string; // 사용자 UUID
113
- nickname: string; // 닉네임
114
- profileImageUrl: string; // 프로필 이미지 URL
115
- role: "user" | "admin"; // 역할
116
- approvedClients: string[]; // 승인된 클라이언트 ID 목록
117
- }
118
- ```
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>;
39
+ uid: string;
40
+ nickname: string;
41
+ profileImageUrl: string;
42
+ role: "user" | "admin";
43
+ approvedClients: string[];
136
44
  }
137
45
  ```
138
46
 
139
- 반환되는 `LemonSession`:
140
-
141
- ```ts
142
- type LemonSession =
143
- | { type: "none" }
144
- | { type: "unapproved"; user: LemonUser }
145
- | { type: "authenticated"; user: LemonUser };
146
- ```
147
-
148
- ### `requireAuth(redirectTo?)`
47
+ ### `getSession({ clientId })`
149
48
 
150
- 인증된 유저를 반환합니다. 미인증이면 `redirectTo`로 redirect합니다.
49
+ 로그인 상태와 클라이언트 승인 상태를 함께 반환합니다. `clientId`가 빈 값(환경변수 누락 등)이면 모든 유저가 `unapproved`가 됩니다. 승인 없이 로그인 여부만 필요하면 `getUser()`를 씁니다.
151
50
 
152
51
  ```ts
153
- import { requireAuth } from "@lemondouble/lemon-auth/server";
154
-
155
- export default async function ProtectedPage() {
156
- const user = await requireAuth(); // 미인증 시 "/" 로 redirect
157
- // const user = await requireAuth("/login"); // 미인증 시 "/login" 으로 redirect
158
-
159
- return <p>{user.nickname}님의 대시보드</p>;
160
- }
52
+ const session = await getSession({ clientId: process.env.CLIENT_ID! });
53
+ // { type: "none" } | { type: "unapproved"; user } | { type: "authenticated"; user }
161
54
  ```
162
55
 
163
- | 파라미터 | 타입 | 기본값 | 설명 |
164
- |---------|------|--------|------|
165
- | `redirectTo` | `string` | `"/"` | 미인증 시 redirect 경로 |
166
-
167
56
  ### `requireClient(clientId, options?)`
168
57
 
169
- `requireAuth()` + `approved_clients` 체크. 관리자가 승인한 사용자만 접근할 수 있는 서비스에서 사용합니다.
170
-
171
- ```ts
172
- import { requireClient } from "@lemondouble/lemon-auth/server";
173
-
174
- const CLIENT_ID = process.env.CLIENT_ID!;
58
+ 승인된 유저만 반환하고, 아니면 redirect합니다.
175
59
 
176
- export default async function Page() {
177
- const user = await requireClient(CLIENT_ID, {
178
- loginRedirectTo: "/",
179
- unapprovedRedirectTo: "/pending-approval",
180
- });
181
- return <p>{user.nickname}</p>;
182
- }
183
- ```
184
-
185
- | 파라미터 | 타입 | 기본값 | 설명 |
186
- |---------|------|--------|------|
187
- | `clientId` | `string` | (필수) | 체크할 클라이언트 UUID |
188
- | `options.loginRedirectTo` | `string` | `"/"` | 미인증 시 redirect 경로 |
189
- | `options.unapprovedRedirectTo` | `string` | `undefined` | 로그인은 됐지만 클라이언트 미승인 시 redirect 경로. 미설정 시 auth-server `/error?code=FORBIDDEN`로 redirect (proxy 동작과 동일) |
60
+ | 옵션 | 기본값 | 설명 |
61
+ |------|--------|------|
62
+ | `loginRedirectTo` | `"/"` | 미로그인 시 redirect 경로 |
63
+ | `unapprovedRedirectTo` | auth 서버 `/error?code=FORBIDDEN` | 미승인 시 redirect 경로 |
190
64
 
191
- ### 상수
65
+ ### `loginUrl()` / `profileUrl()`
192
66
 
193
- 서버 엔트리포인트에서 모든 상수를 re-export합니다.
194
-
195
- ```ts
196
- import {
197
- AUTH_SERVER_URL, // "https://auth.lemondouble.com"
198
- JWKS_URL, // "https://auth.lemondouble.com/.well-known/jwks.json"
199
- LOGIN_URL, // "https://auth.lemondouble.com/api/oauth2/google/login"
200
- REFRESH_URL, // "https://auth.lemondouble.com/api/token/refresh"
201
- LOGOUT_URL, // "https://auth.lemondouble.com/api/token/logout"
202
- PROFILE_URL, // "https://auth.lemondouble.com/api/user/me"
203
- PROFILE_PAGE_URL, // "https://auth.lemondouble.com/profile"
204
- ACCESS_TOKEN_COOKIE, // "lemon_access_token"
205
- REFRESH_TOKEN_COOKIE, // "lemon_refresh_token"
206
- DEVICE_ID_COOKIE, // "device_id"
207
- } from "@lemondouble/lemon-auth/server";
208
- ```
67
+ Client 엔트리포인트의 같은 함수를 Server Component에서 쓸 수 있게 re-export합니다.
209
68
 
210
69
  ---
211
70
 
212
71
  ## Proxy — `@lemondouble/lemon-auth/proxy`
213
72
 
214
- Next.js 16의 `proxy.ts`에서 사용합니다. **쿠키만 읽는 optimistic check** 만 합니다.
215
-
216
- > 프록시는 토큰을 갱신하지 않고 JWT 서명도 검증하지 않습니다. 갱신은
217
- > `<SessionRestore />`(브라우저)가 유일하게 맡고, 진짜 검증은
218
- > DAL(`getSession` / `requireClient`)이 합니다. 마이그레이션은
219
- > [0.8.x → 0.9.0](#08x--090-마이그레이션) 참고.
220
-
221
- Next.js 공식 인증 가이드의 권장 구조를 그대로 따릅니다.
222
-
223
- > since Proxy runs on every route, including prefetched routes, it's important to
224
- > only read the session from the cookie (optimistic checks), and avoid database
225
- > checks to prevent performance issues.
226
- >
227
- > — [Next.js — Authentication](https://nextjs.org/docs/app/guides/authentication)
228
-
229
73
  ### `createAuthProxy(options?)`
230
74
 
231
- ```ts
232
- // proxy.ts (프로젝트 루트)
233
- import { createAuthProxy } from "@lemondouble/lemon-auth/proxy";
234
-
235
- export default createAuthProxy({
236
- publicPaths: ["/", "/about", "/api/public/*"],
237
- bypassPaths: ["/workbox-*"],
238
- loginRedirectUrl: (request) => request.url,
239
- });
240
-
241
- export const config = {
242
- matcher: [
243
- "/((?!_next/static|_next/image|sw.js|manifest.webmanifest|favicon.ico|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico|woff|woff2|ttf|eot|otf)$).*)",
244
- ],
245
- };
246
- ```
247
-
248
- #### `AuthProxyOptions`
249
-
250
- | 옵션 | 타입 | 기본값 | 설명 |
251
- |------|------|--------|------|
252
- | `publicPaths` | `string[]` | `[]` | 로그인 없이 열어둘 경로. 익명이면 통과, 만료 세션이면 복구를 거친다. `*`로 끝나면 prefix 매칭 (예: `"/api/public/*"`) |
253
- | `bypassPaths` | `string[]` | `[]` | proxy가 아무 판단 없이 통과시킬 추가 경로. 기본 PWA 경로(`/sw.js`, `/manifest.webmanifest` 등)는 항상 bypass |
254
- | `apiPaths` | `string[]` | `["/api/*"]` | 보호 API 경로. 인증 실패 시 redirect 대신 401 JSON을 반환한다. disable하려면 `[]` |
255
- | `loginRedirectUrl` | `string \| (request: NextRequest) => string` | `undefined` | 미인증 시 Google 로그인 후 돌아올 URL. deep link 보존이 필요하면 `(request) => request.url` 사용. 미설정 시 `"/"` 로 redirect |
256
- | `restorePath` | `string` | `"/auth/restore"` | `<SessionRestore />` 를 마운트한 경로. 프록시가 자동으로 통과시키므로 `publicPaths` 에 넣지 않아도 된다 |
257
-
258
- #### 동작 방식
75
+ | 옵션 | 기본값 | 설명 |
76
+ |------|--------|------|
77
+ | `publicPaths` | `[]` | 로그인 없이 열어둘 경로. `*`로 끝나면 prefix 매칭 |
78
+ | `apiPaths` | `["/api/*"]` | 인증 실패 시 redirect 대신 `401 { "code": "UNAUTHORIZED" }`를 반환할 경로 |
79
+ | `loginRedirectUrl` | `undefined` | 로그인 후 돌아올 URL. 문자열 또는 `(request) => string`. 미설정 시 `"/"`로 redirect |
259
80
 
260
81
  요청은 위에서부터 순서대로 판정됩니다.
261
82
 
262
- 1. `restorePath` → 통과 (여기서 복구 경로로 보내면 무한 루프)
263
- 2. bypass 경로 (기본 PWA/정적 리소스 + `bypassPaths`) → 통과
264
- 3. `lemon_access_token` 이 있고 만료까지 60초 이상 → 통과
265
- 4. — 여기부터는 만료된 요청 —
266
- `apiPaths` 매칭 → 공개 경로면 통과, 아니면 `401 { code: "UNAUTHORIZED" }`
267
- 5. 비-GET 요청 (서버 액션 등) → 통과. 리다이렉트하면 액션 프로토콜이
268
- 깨지므로, DAL 이 거부하게 둡니다
269
- 6. `lemon_refresh_token` 있음 → `restorePath?next=<원래 경로>` 로 redirect.
270
- **공개 경로도 포함입니다**
271
- 7. 공개 경로 → 통과 (익명 방문자)
272
- 8. 나머지 → `loginRedirectUrl` 을 담아 로그인 페이지로 redirect
273
-
274
- 공개 경로도 만료 세션이면 복구를 거치는 이유: 그냥 통과시키면 "로그아웃
275
- 상태로 렌더됐다가 갱신 후 뒤집히는" 화면이 되고, 그걸 가리려는 우회 코드가
276
- 앱마다 생깁니다. 복구를 거치면 처음부터 로그인 상태로 렌더됩니다.
277
-
278
- **프리페치 요청**: 다른 요청과 똑같이 처리합니다. 통과시키면 만료된 토큰으로
279
- 페이지가 렌더되고 거기서 나온 redirect 가 라우터 캐시에 굳어, 어느 링크를 눌러도
280
- 같은 곳으로 튀게 됩니다. 반면 프록시가 돌려주는 `restorePath` 리다이렉트는 굳어도
281
- 해가 없습니다 — 복구 경로는 막다른 길이 아니라 세션을 되살리고 `next` 로
282
- 되돌려 보내므로 목적지도 보존됩니다.
283
-
284
- `exp` 만 보고 **서명은 검증하지 않습니다.** 위조 토큰으로 이 검사는 통과할 수
285
- 있으나 DAL 에서 걸립니다. 프록시의 판단은 "어느 화면으로 보낼까" 이지
286
- "권한이 있는가" 가 아닙니다. 권한 판단은 반드시 DAL 에서 하십시오.
287
-
288
- `loginRedirectUrl`은 고정 URL 문자열과 요청별 resolver 함수를 모두 지원합니다.
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. 나머지 → 로그인 페이지
289
90
 
290
- ```ts
291
- createAuthProxy({
292
- // 로그인 후 항상 같은 화면으로 복귀
293
- loginRedirectUrl: "https://myapp.lemondouble.com/dashboard",
294
- });
91
+ 앱 안에서 링크로 이동할 때(소프트 내비게이션)도 복구 화면을 받으면, Next 라우터가 같은 주소로 전체 페이지 이동을 해서 복구가 이어집니다.
295
92
 
296
- createAuthProxy({
297
- // 로그인 후 사용자가 원래 요청한 path/query로 복귀
298
- loginRedirectUrl: (request) => request.url,
299
- });
300
- ```
301
-
302
- ### 0.8.x → 0.9.0 마이그레이션
303
-
304
- 갱신 주체가 `<SessionRestore />` 하나로 통일되고, 실패 처리가 서버로
305
- 넘어갔습니다. **auth-server v2026.08.03.2 이상이 먼저 배포되어 있어야 합니다**
306
- — 죽은 토큰의 401 응답이 세션 쿠키를 지워주는 동작에 의존합니다.
307
-
308
- **1. `<AutoTokenRefresh />` 제거** — export 자체가 사라졌습니다. 루트
309
- 레이아웃에서 마운트와 import 를 지웁니다. 공개 경로의 만료 세션은 이제
310
- 프록시가 복구 경로로 보내 처리합니다.
311
-
312
- **2. `publicPaths` 에서 복구 경로 제거** — `restorePath` 는 프록시가 자동으로
313
- 통과시킵니다. 남겨둬도 해는 없지만 필요 없습니다.
314
-
315
- **3. `refreshTokenFromCookie` 제거** — 서버사이드 갱신 진입점이 사라졌습니다.
316
- 갱신은 브라우저(`<SessionRestore />`)만 합니다.
317
-
318
- **4. `<SessionRestore failedRedirectUrl>` prop 제거** — 실패해도 `next` 로
319
- 돌아갑니다. 죽은 토큰의 쿠키는 서버가 지워주므로, 이후 판단(로그인/익명)은
320
- 프록시와 DAL 이 합니다.
321
-
322
- **5. 공개 페이지의 "복구 중" 우회 코드 제거** — 공개 경로도 만료 세션이면
323
- 복구를 거친 뒤 처음부터 로그인 상태로 렌더되므로, refresh 쿠키 유무를 보고
324
- 스피너를 띄우던 코드는 필요 없습니다.
325
-
326
- ### API 경로
93
+ ### 0.10.x → 0.11.0 마이그레이션
327
94
 
328
- 보호 경로 중 `apiPaths`(기본 `/api/*`)에 매칭되는 요청은 인증 실패 시 redirect 대신 JSON 응답을 반환합니다. fetch 호출이 OAuth HTML로 리다이렉트되어 계약이 깨지는 문제를 방지합니다.
329
-
330
- | 상황 | 응답 |
331
- |------|------|
332
- | 미인증 (access/refresh 모두 실패) | `401 { "code": "UNAUTHORIZED" }` |
333
- | 미승인 (`clientId` 미포함) | `403 { "code": "FORBIDDEN" }` |
334
-
335
- `PROXY_AUTH_ERROR` 상수와 `ProxyAuthErrorCode` 타입을 export합니다.
336
-
337
- ```ts
338
- import { PROXY_AUTH_ERROR } from "@lemondouble/lemon-auth/proxy";
339
- import type { ProxyAuthErrorCode } from "@lemondouble/lemon-auth/proxy";
340
-
341
- const res = await fetch("/api/users");
342
- if (!res.ok) {
343
- const data = (await res.json()) as { code: ProxyAuthErrorCode };
344
- if (data.code === PROXY_AUTH_ERROR.UNAUTHORIZED) {
345
- // 재로그인 유도
346
- }
347
- if (data.code === PROXY_AUTH_ERROR.FORBIDDEN) {
348
- // "권한 없음" 표시
349
- }
350
- }
351
- ```
352
-
353
- `/api/*` 외의 경로(예: tRPC, GraphQL)도 JSON 응답을 받게 하려면 `apiPaths`에 추가합니다.
354
-
355
- ```ts
356
- import { DEFAULT_API_PATHS } from "@lemondouble/lemon-auth/proxy";
357
-
358
- createAuthProxy({
359
- apiPaths: [...DEFAULT_API_PATHS, "/trpc/*"],
360
- });
361
- ```
362
-
363
- `apiPaths: []`로 두면 모든 보호 경로가 redirect로 동작합니다 (이전 버전 동작).
95
+ - `<SessionRestore>`를 제거했습니다. 프록시가 복구 화면을 직접 응답하므로 `app/auth/restore/page.tsx`를 삭제합니다.
364
96
 
365
97
  ---
366
98
 
367
99
  ## Client — `@lemondouble/lemon-auth/client`
368
100
 
369
- Client Components에서 사용합니다.
370
-
371
- ### `<AuthProvider>` + `useAuth()`
372
-
373
- Server Component에서 유저 정보를 받아 Client Component 트리에 전달합니다.
374
-
375
- ```tsx
376
- // app/layout.tsx (Server Component)
377
- import { getUser } from "@lemondouble/lemon-auth/server";
378
- import { AuthProvider } from "@lemondouble/lemon-auth/client";
379
-
380
- export default async function RootLayout({ children }: { children: React.ReactNode }) {
381
- const user = await getUser();
382
-
383
- return (
384
- <html lang="ko">
385
- <body>
386
- <AuthProvider user={user}>
387
- {children}
388
- </AuthProvider>
389
- </body>
390
- </html>
391
- );
392
- }
393
- ```
394
-
395
- ```tsx
396
- // components/nav.tsx (Client Component)
397
- "use client";
398
- import { useAuth } from "@lemondouble/lemon-auth/client";
399
-
400
- export function Nav() {
401
- const { user, isAuthenticated } = useAuth();
402
- // user: LemonUser | null
403
- // isAuthenticated: boolean
404
-
405
- if (!isAuthenticated) return <a href="/login">로그인</a>;
406
- return <span>{user!.nickname}</span>;
407
- }
408
- ```
409
-
410
- ### `<SessionRestore>`
411
-
412
- **토큰을 갱신하는 유일한 주체입니다.** 프록시가 만료된 세션을 이리로 보내고,
413
- 갱신이 끝나면 `next` 파라미터가 가리키는 곳으로 **하드 네비게이션**
414
- (`window.location.replace`) 합니다. 소프트 네비는 루트 레이아웃과
415
- `AuthProvider` 를 다시 렌더하지 않아, 옛 세션 스냅샷이 남은 채 화면이
416
- 어긋나기 때문입니다.
417
-
418
- ```tsx
419
- // app/auth/restore/page.tsx
420
- import { Suspense } from "react";
421
- import { SessionRestore } from "@lemondouble/lemon-auth/client";
422
-
423
- export const dynamic = "force-dynamic";
424
-
425
- export default function RestorePage() {
426
- return (
427
- <Suspense fallback={null}>
428
- <SessionRestore fallback={<p>세션 복원 중...</p>} />
429
- </Suspense>
430
- );
431
- }
432
- ```
433
-
434
- | prop | 타입 | 필수 | 설명 |
435
- |------|------|------|------|
436
- | `fallback` | `React.ReactNode` | **필수** | 갱신하는 동안 렌더할 UI |
437
-
438
- - 갱신 실패도 `next` 로 돌아갑니다. 죽은 토큰이면 서버가 401 응답에서 세션
439
- 쿠키를 지워 주므로, 이후 판단(로그인으로 보낼지, 익명으로 보여줄지)은
440
- 프록시와 DAL 이 합니다.
441
- - 서버에 연결하지 못하면(5xx · 네트워크 오류) 로그인으로 보내지 않고
442
- "다시 시도" 버튼을 보여줍니다. 토큰이 살아 있을 수 있기 때문입니다.
443
- - 이 경로는 프록시가 자동으로 통과시키므로 `publicPaths` 에 넣지 않아도
444
- 됩니다.
445
- - `useSearchParams()` 를 쓰므로 `<Suspense>` 로 감싸야 빌드가 통과합니다.
446
- - `next` 는 같은 출처의 경로만 허용합니다. 열린 리다이렉트가 되지 않도록
447
- `//evil.com` 같은 값은 `/` 로 떨어뜨립니다.
448
-
449
- ### `loginUrl(redirectUrl)`
450
-
451
- Google 로그인 URL을 생성합니다. `<a>` 태그나 `window.location.href`에 사용합니다.
452
-
453
- ```ts
454
- import { loginUrl } from "@lemondouble/lemon-auth/client";
455
-
456
- loginUrl("https://myapp.lemondouble.com/dashboard")
457
- // → "https://auth.lemondouble.com/api/oauth2/google/login?redirect_url=https%3A%2F%2Fmyapp.lemondouble.com%2Fdashboard"
458
- ```
459
-
460
- | 파라미터 | 타입 | 설명 |
461
- |---------|------|------|
462
- | `redirectUrl` | `string` | 로그인 완료 후 돌아올 절대 URL (`https://` + `lemondouble.com` 또는 `*.lemondouble.com`만 허용) |
463
-
464
- 허용되지 않는 URL이면 `Error`를 throw합니다. Mock User가 활성화된 로컬 개발 환경에서는 검증하지 않고 `redirectUrl`을 그대로 반환합니다.
465
-
466
- ### `logout()`
467
-
468
- 로그아웃을 수행합니다. `lemon_refresh_token`을 폐기하고 쿠키를 삭제합니다.
469
- 서버 응답이 성공이면 `true`, 실패 응답 또는 네트워크 예외면 `false`를 반환합니다.
470
- 반환값을 무시하고 기존처럼 `await logout()`만 호출해도 됩니다.
471
-
472
- ```tsx
473
- "use client";
474
- import { logout } from "@lemondouble/lemon-auth/client";
475
-
476
- function LogoutButton() {
477
- const handleLogout = async () => {
478
- const ok = await logout();
479
- if (!ok) {
480
- // 필요하면 실패 안내를 표시
481
- }
482
- window.location.href = "/";
483
- };
484
-
485
- return <button onClick={handleLogout}>로그아웃</button>;
486
- }
487
- ```
488
-
489
- ### `profileUrl(redirectUrl?)`
490
-
491
- 프로필 편집 페이지 URL을 생성합니다. 닉네임/프로필 이미지 변경 페이지로 이동시킬 때 사용합니다.
492
-
493
- ```ts
494
- import { profileUrl } from "@lemondouble/lemon-auth/client";
495
-
496
- profileUrl()
497
- // → "https://auth.lemondouble.com/profile"
498
-
499
- profileUrl("https://myapp.lemondouble.com/settings")
500
- // → "https://auth.lemondouble.com/profile?redirect_url=https%3A%2F%2Fmyapp.lemondouble.com%2Fsettings"
501
- ```
502
-
503
- | 파라미터 | 타입 | 기본값 | 설명 |
504
- |---------|------|--------|------|
505
- | `redirectUrl` | `string` | `undefined` | 편집 완료 후 돌아올 URL. 설정하면 "← 돌아가기" 버튼이 표시됨 |
506
-
507
- ### `refreshToken()`
508
-
509
- 클라이언트에서 토큰 갱신을 수행합니다. 갱신은 보통 `<SessionRestore />` 가
510
- 맡으므로 직접 호출할 일은 적습니다. (승인 완료 후 새 `approved_clients` 를
511
- 즉시 반영하고 싶을 때 정도)
512
-
513
- ```ts
514
- import { refreshToken } from "@lemondouble/lemon-auth/client";
515
-
516
- const ok = await refreshToken();
517
- // → boolean (갱신 성공 여부)
518
- ```
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`를 반영할 때 씁니다 |
519
108
 
520
109
  ---
521
110
 
522
- ## 타입
111
+ ## 연동 예시
523
112
 
524
- 세 엔트리포인트 모두에서 타입을 import할 수 있습니다.
113
+ ### `proxy.ts`
525
114
 
526
115
  ```ts
527
- import type { LemonUser, AccessTokenClaims } from "@lemondouble/lemon-auth/server";
528
- import type { LemonUser } from "@lemondouble/lemon-auth/client";
529
- import type { LemonUser, AccessTokenClaims } from "@lemondouble/lemon-auth/proxy";
530
- ```
531
-
532
- ```ts
533
- interface LemonUser {
534
- uid: string;
535
- nickname: string;
536
- profileImageUrl: string;
537
- role: "user" | "admin";
538
- approvedClients: string[];
539
- }
540
-
541
- interface AccessTokenClaims extends JWTPayload {
542
- token_type: "access";
543
- sub: string;
544
- nickname: string;
545
- profile_image_url: string;
546
- role: "user" | "admin";
547
- approved_clients: string[];
548
- }
549
-
550
- interface UserProfile {
551
- uid: string;
552
- nickname: string;
553
- profile_image_url: string;
554
- role: "user" | "admin";
555
- }
556
- ```
557
-
558
- ---
559
-
560
- ## 전체 연동 예시
561
-
562
- 새 Next.js 16 프로젝트에 인증을 추가하는 권장 예시입니다.
563
-
564
- ### 1. proxy.ts — optimistic check
565
-
566
- ```ts
567
- import {
568
- createAuthProxy,
569
- DEFAULT_API_PATHS,
570
- } from "@lemondouble/lemon-auth/proxy";
116
+ import { createAuthProxy } from "@lemondouble/lemon-auth/proxy";
571
117
 
572
118
  export default createAuthProxy({
573
- publicPaths: [
574
- "/",
575
- "/login",
576
- "/pending-approval",
577
- "/api/public/*",
578
- ],
579
- bypassPaths: ["/workbox-*"],
580
- apiPaths: DEFAULT_API_PATHS,
119
+ publicPaths: ["/", "/pending-approval", "/api/public/*"],
581
120
  loginRedirectUrl: (request) => request.url,
582
121
  });
583
122
 
@@ -588,47 +127,7 @@ export const config = {
588
127
  };
589
128
  ```
590
129
 
591
- `loginRedirectUrl: (request) => request.url`은 로그인 후 사용자가 원래 요청한 path/query로 돌아오게 합니다.
592
- `apiPaths`에 매칭되는 보호 API는 인증 실패 시 redirect 대신 `401` JSON 응답을 반환합니다.
593
- 기본 PWA 파일(`/sw.js`, `/manifest.webmanifest` 등)은 항상 bypass되며, 앱에서 추가로 생성하는 workbox 파일은 `bypassPaths`에 넣을 수 있습니다.
594
- 복구 경로(`/auth/restore`)는 프록시가 자동으로 통과시키므로 `publicPaths` 에 넣지 않습니다.
595
-
596
- 사용자 DB 동기화 같은 작업은 **프록시가 아니라 DAL 에서** 하십시오. 프록시는
597
- 프리페치를 포함해 모든 요청에서 돌기 때문에, 그 자리에서 DB 를 건드리면 화면
598
- 하나 여는 데 쿼리가 여러 번 나갑니다.
599
-
600
- ```ts
601
- // lib/dal.ts
602
- import { cache } from "react";
603
- import { getUser } from "@lemondouble/lemon-auth/server";
604
- import { upsertUser } from "@/lib/db";
605
-
606
- export const getCurrentUser = cache(async () => {
607
- const user = await getUser();
608
- if (!user) return null;
609
- await upsertUser(user); // React cache() 덕에 요청당 한 번만 돈다
610
- return user;
611
- });
612
- ```
613
-
614
- ### 2. app/auth/restore/page.tsx — 세션 복구 착지점
615
-
616
- ```tsx
617
- import { Suspense } from "react";
618
- import { SessionRestore } from "@lemondouble/lemon-auth/client";
619
-
620
- export const dynamic = "force-dynamic";
621
-
622
- export default function RestorePage() {
623
- return (
624
- <Suspense fallback={null}>
625
- <SessionRestore fallback={<p>세션 복원 중...</p>} />
626
- </Suspense>
627
- );
628
- }
629
- ```
630
-
631
- ### 3. app/layout.tsx — AuthProvider
130
+ ### `app/layout.tsx`
632
131
 
633
132
  ```tsx
634
133
  import { getUser } from "@lemondouble/lemon-auth/server";
@@ -646,50 +145,30 @@ export default async function RootLayout({ children }: { children: React.ReactNo
646
145
  }
647
146
  ```
648
147
 
649
- 레이아웃은 `user` 를 **표시용**으로만 씁니다. 레이아웃은 소프트 네비에서
650
- 다시 렌더되지 않으므로, 여기서 인증을 판단하면 낡은 스냅샷으로 판단하게
651
- 됩니다. 게이트는 페이지의 DAL 호출이 맡습니다.
148
+ 레이아웃은 소프트 네비게이션에서 다시 렌더되지 않으므로 `user`를 표시용으로만 씁니다. 접근 제어는 페이지에서 합니다.
652
149
 
653
- ### 4. app/page.tsx — 공개 페이지 (로그인 버튼)
150
+ ### 보호 페이지
654
151
 
655
152
  ```tsx
656
- import { getUser } from "@lemondouble/lemon-auth/server";
657
- import { redirect } from "next/navigation";
658
- import { LoginButton } from "@/components/login-button";
153
+ import { requireClient } from "@lemondouble/lemon-auth/server";
659
154
 
660
- export default async function Home() {
661
- const user = await getUser();
662
- if (user) redirect("/dashboard");
663
- return <LoginButton />;
155
+ export default async function Dashboard() {
156
+ const user = await requireClient(process.env.CLIENT_ID!, {
157
+ unapprovedRedirectTo: "/pending-approval",
158
+ });
159
+ return <h1>{user.nickname}님의 대시보드</h1>;
664
160
  }
665
161
  ```
666
162
 
163
+ ### 로그인 · 로그아웃 버튼
164
+
667
165
  ```tsx
668
- // components/login-button.tsx
669
166
  "use client";
670
- import { loginUrl } from "@lemondouble/lemon-auth/client";
167
+ import { loginUrl, logout } from "@lemondouble/lemon-auth/client";
671
168
 
672
169
  export function LoginButton() {
673
170
  return <a href={loginUrl("https://myapp.lemondouble.com/dashboard")}>Google로 로그인</a>;
674
171
  }
675
- ```
676
-
677
- ### 5. app/dashboard/page.tsx — 보호 페이지
678
-
679
- ```tsx
680
- import { requireAuth } from "@lemondouble/lemon-auth/server";
681
-
682
- export default async function Dashboard() {
683
- const user = await requireAuth();
684
- return <h1>{user.nickname}님의 대시보드</h1>;
685
- }
686
- ```
687
-
688
- ### 6. 로그아웃
689
-
690
- ```tsx
691
- "use client";
692
- import { logout } from "@lemondouble/lemon-auth/client";
693
172
 
694
173
  export function LogoutButton() {
695
174
  return (
@@ -704,3 +183,17 @@ export function LogoutButton() {
704
183
  );
705
184
  }
706
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`를 반환합니다.