@thinkingcat/auth-utils 1.0.49 → 2.0.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/dist/core/auth-response.d.ts +15 -0
- package/dist/core/auth-response.js +45 -0
- package/dist/core/cookies.d.ts +33 -0
- package/dist/core/cookies.js +102 -0
- package/dist/core/jwt.d.ts +28 -0
- package/dist/core/jwt.js +180 -0
- package/dist/core/roles.d.ts +25 -0
- package/dist/core/roles.js +61 -0
- package/dist/core/verify.d.ts +40 -0
- package/dist/core/verify.js +181 -0
- package/dist/index.d.ts +18 -634
- package/dist/index.js +34 -1642
- package/dist/middleware/config.d.ts +15 -0
- package/dist/middleware/config.js +38 -0
- package/dist/middleware/handler.d.ts +6 -0
- package/dist/middleware/handler.js +228 -0
- package/dist/nextauth/callbacks.d.ts +35 -0
- package/dist/nextauth/callbacks.js +143 -0
- package/dist/nextauth/config.d.ts +50 -0
- package/dist/nextauth/config.js +66 -0
- package/dist/nextauth/session.d.ts +10 -0
- package/dist/nextauth/session.js +40 -0
- package/dist/sso/api.d.ts +23 -0
- package/dist/sso/api.js +83 -0
- package/dist/types/index.d.ts +138 -0
- package/dist/types/index.js +2 -0
- package/dist/utils/crypto.d.ts +4 -0
- package/dist/utils/crypto.js +13 -0
- package/dist/utils/license.d.ts +4 -0
- package/dist/utils/license.js +20 -0
- package/dist/utils/logger.d.ts +5 -0
- package/dist/utils/logger.js +17 -0
- package/dist/utils/path.d.ts +12 -0
- package/dist/utils/path.js +25 -0
- package/dist/utils/redirect.d.ts +20 -0
- package/dist/utils/redirect.js +106 -0
- package/dist/utils/server.d.ts +15 -0
- package/dist/utils/server.js +42 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,1642 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
* NextAuth 세션 토큰 쿠키를 삭제하는 헬퍼 함수
|
|
36
|
-
*/
|
|
37
|
-
function deleteNextAuthSessionCookie(response, isProduction) {
|
|
38
|
-
const cookieName = isProduction
|
|
39
|
-
? '__Secure-next-auth.session-token'
|
|
40
|
-
: 'next-auth.session-token';
|
|
41
|
-
response.cookies.delete(cookieName);
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* 모든 인증 관련 쿠키를 삭제하는 헬퍼 함수
|
|
45
|
-
*/
|
|
46
|
-
function clearAllAuthCookies(response, cookiePrefix, isProduction) {
|
|
47
|
-
clearAuthCookies(response, cookiePrefix);
|
|
48
|
-
deleteNextAuthSessionCookie(response, isProduction);
|
|
49
|
-
}
|
|
50
|
-
// ============================================================================
|
|
51
|
-
// JWT CORE FUNCTIONS
|
|
52
|
-
// ============================================================================
|
|
53
|
-
/**
|
|
54
|
-
* 토큰 검증 및 디코딩
|
|
55
|
-
* @param accessToken JWT access token
|
|
56
|
-
* @param secret JWT 서명에 사용할 secret key
|
|
57
|
-
* @returns 검증된 payload 또는 null
|
|
58
|
-
*/
|
|
59
|
-
export async function verifyToken(accessToken, secret) {
|
|
60
|
-
try {
|
|
61
|
-
const secretBytes = new TextEncoder().encode(secret);
|
|
62
|
-
const { payload } = await jwtVerify(accessToken, secretBytes);
|
|
63
|
-
if (payload && typeof payload === 'object' && payload.email) {
|
|
64
|
-
return { payload: payload };
|
|
65
|
-
}
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* payload에서 역할 추출 (서비스별)
|
|
74
|
-
* @param payload JWT payload
|
|
75
|
-
* @param serviceId 서비스 ID (필수)
|
|
76
|
-
* @param defaultRole 기본 역할 (기본값: 'ADMIN')
|
|
77
|
-
* @returns 추출된 역할
|
|
78
|
-
*/
|
|
79
|
-
export function extractRoleFromPayload(payload, serviceId, defaultRole = 'ADMIN') {
|
|
80
|
-
const services = payload.services || [];
|
|
81
|
-
const service = services.find((s) => s.serviceId === serviceId);
|
|
82
|
-
return service?.role || payload.role || defaultRole;
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* payload에서 NextAuth JWT 객체 생성
|
|
86
|
-
* @param payload JWT payload
|
|
87
|
-
* @param serviceId 서비스 ID (필수)
|
|
88
|
-
* @returns NextAuth JWT 객체
|
|
89
|
-
*/
|
|
90
|
-
export function createNextAuthJWT(payload, serviceId) {
|
|
91
|
-
const services = payload.services || [];
|
|
92
|
-
const service = services.find((s) => s.serviceId === serviceId);
|
|
93
|
-
const effectiveRole = service?.role || payload.role || 'ADMIN';
|
|
94
|
-
// name 필드 결정: payload.name이 있으면 사용, 없으면 decryptedEmail 또는 maskedEmail 사용
|
|
95
|
-
// email은 암호화되어 있을 수 있으므로 직접 사용하지 않음
|
|
96
|
-
const displayName = payload.name
|
|
97
|
-
|| payload.decryptedEmail
|
|
98
|
-
|| payload.maskedEmail
|
|
99
|
-
|| 'User';
|
|
100
|
-
const jwt = {
|
|
101
|
-
id: (payload.id || payload.sub),
|
|
102
|
-
email: payload.email,
|
|
103
|
-
name: displayName,
|
|
104
|
-
role: effectiveRole, // Role enum 타입 (string으로 캐스팅)
|
|
105
|
-
services: payload.services,
|
|
106
|
-
phoneVerified: payload.phoneVerified ?? false,
|
|
107
|
-
emailVerified: payload.emailVerified ?? false,
|
|
108
|
-
smsVerified: payload.smsVerified ?? false,
|
|
109
|
-
iat: Math.floor(Date.now() / 1000),
|
|
110
|
-
exp: Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60), // 30일
|
|
111
|
-
};
|
|
112
|
-
// 선택적 필드들
|
|
113
|
-
if (payload.phone)
|
|
114
|
-
jwt.phone = payload.phone;
|
|
115
|
-
if (payload.isPasswordReset)
|
|
116
|
-
jwt.isPasswordReset = payload.isPasswordReset;
|
|
117
|
-
if (payload.decryptedEmail)
|
|
118
|
-
jwt.decryptedEmail = payload.decryptedEmail;
|
|
119
|
-
if (payload.decryptedPhone)
|
|
120
|
-
jwt.decryptedPhone = payload.decryptedPhone;
|
|
121
|
-
if (payload.emailHash)
|
|
122
|
-
jwt.emailHash = payload.emailHash;
|
|
123
|
-
if (payload.maskedEmail)
|
|
124
|
-
jwt.maskedEmail = payload.maskedEmail;
|
|
125
|
-
if (payload.phoneHash)
|
|
126
|
-
jwt.phoneHash = payload.phoneHash;
|
|
127
|
-
if (payload.maskedPhone)
|
|
128
|
-
jwt.maskedPhone = payload.maskedPhone;
|
|
129
|
-
if (payload.refreshToken)
|
|
130
|
-
jwt.refreshToken = payload.refreshToken;
|
|
131
|
-
if (payload.accessToken)
|
|
132
|
-
jwt.accessToken = payload.accessToken;
|
|
133
|
-
if (payload.accessTokenExpires)
|
|
134
|
-
jwt.accessTokenExpires = payload.accessTokenExpires;
|
|
135
|
-
if (payload.serviceId)
|
|
136
|
-
jwt.serviceId = payload.serviceId;
|
|
137
|
-
return jwt;
|
|
138
|
-
}
|
|
139
|
-
/**
|
|
140
|
-
* NextAuth JWT를 인코딩된 세션 토큰으로 변환
|
|
141
|
-
* @param jwt NextAuth JWT 객체
|
|
142
|
-
* @param secret JWT 서명에 사용할 secret key
|
|
143
|
-
* @param maxAge 토큰 유효 기간 (초, 기본값: 30일)
|
|
144
|
-
* @returns 인코딩된 세션 토큰
|
|
145
|
-
*/
|
|
146
|
-
export async function encodeNextAuthToken(jwt, secret, maxAge = 30 * 24 * 60 * 60) {
|
|
147
|
-
// NextAuth의 encode() 함수를 우선적으로 사용 (가장 호환성 좋음)
|
|
148
|
-
try {
|
|
149
|
-
const { encode } = await import('next-auth/jwt');
|
|
150
|
-
debugLog('encodeNextAuthToken', 'Using next-auth/jwt encode');
|
|
151
|
-
const encoded = await encode({
|
|
152
|
-
token: jwt,
|
|
153
|
-
secret: secret,
|
|
154
|
-
maxAge: maxAge,
|
|
155
|
-
});
|
|
156
|
-
debugLog('encodeNextAuthToken', 'NextAuth encode successful, length:', encoded.length);
|
|
157
|
-
return encoded;
|
|
158
|
-
}
|
|
159
|
-
catch (error) {
|
|
160
|
-
// Edge Runtime에서 encode가 작동하지 않을 수 있으므로
|
|
161
|
-
// jose의 EncryptJWT를 fallback으로 사용
|
|
162
|
-
debugLog('encodeNextAuthToken', 'NextAuth encode failed, using jose EncryptJWT fallback', error);
|
|
163
|
-
// NextAuth는 secret을 SHA-256 해시하여 32바이트 키로 사용
|
|
164
|
-
const secretHash = await createHashSHA256(secret);
|
|
165
|
-
// SHA-256 해시는 64자 hex 문자열이므로, 32바이트로 변환
|
|
166
|
-
const keyBytes = new Uint8Array(32);
|
|
167
|
-
for (let i = 0; i < 32; i++) {
|
|
168
|
-
keyBytes[i] = parseInt(secretHash.slice(i * 2, i * 2 + 2), 16);
|
|
169
|
-
}
|
|
170
|
-
const now = Math.floor(Date.now() / 1000);
|
|
171
|
-
// EncryptJWT를 사용하여 JWE 토큰 생성
|
|
172
|
-
// NextAuth는 'dir' 키 관리와 'A256GCM' 암호화를 사용
|
|
173
|
-
try {
|
|
174
|
-
const token = await new EncryptJWT(jwt)
|
|
175
|
-
.setProtectedHeader({
|
|
176
|
-
alg: 'dir', // Direct key agreement
|
|
177
|
-
enc: 'A256GCM' // AES-256-GCM encryption
|
|
178
|
-
})
|
|
179
|
-
.setIssuedAt(now)
|
|
180
|
-
.setExpirationTime(now + maxAge)
|
|
181
|
-
.setJti(crypto.randomUUID())
|
|
182
|
-
.encrypt(keyBytes);
|
|
183
|
-
debugLog('encodeNextAuthToken', 'EncryptJWT fallback successful, length:', token.length);
|
|
184
|
-
return token;
|
|
185
|
-
}
|
|
186
|
-
catch (encryptError) {
|
|
187
|
-
debugError('encodeNextAuthToken', 'EncryptJWT also failed:', encryptError);
|
|
188
|
-
throw new Error(`Failed to encode NextAuth token: ${error instanceof Error ? error.message : String(error)}`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
export function setCustomTokens(response, accessToken, optionsOrRefreshToken, options) {
|
|
193
|
-
// 옵션 파라미터 처리: refreshToken과 options를 분리
|
|
194
|
-
let refreshTokenValue;
|
|
195
|
-
let cookiePrefix;
|
|
196
|
-
let isProduction;
|
|
197
|
-
let cookieDomain;
|
|
198
|
-
if (typeof optionsOrRefreshToken === 'string') {
|
|
199
|
-
// 기존 방식: refreshToken이 문자열로 전달된 경우
|
|
200
|
-
refreshTokenValue = optionsOrRefreshToken;
|
|
201
|
-
const { cookiePrefix: prefix, isProduction: prod = false, cookieDomain: domain, } = options || {};
|
|
202
|
-
if (!prefix) {
|
|
203
|
-
throw new Error('cookiePrefix is required');
|
|
204
|
-
}
|
|
205
|
-
cookiePrefix = prefix;
|
|
206
|
-
isProduction = prod;
|
|
207
|
-
cookieDomain = domain;
|
|
208
|
-
}
|
|
209
|
-
else {
|
|
210
|
-
// 새로운 방식: options 객체로 전달된 경우
|
|
211
|
-
const opts = optionsOrRefreshToken || {};
|
|
212
|
-
refreshTokenValue = opts.refreshToken;
|
|
213
|
-
if (!opts.cookiePrefix) {
|
|
214
|
-
throw new Error('cookiePrefix is required');
|
|
215
|
-
}
|
|
216
|
-
cookiePrefix = opts.cookiePrefix;
|
|
217
|
-
isProduction = opts.isProduction || false;
|
|
218
|
-
cookieDomain = opts.cookieDomain;
|
|
219
|
-
}
|
|
220
|
-
// 쿠키 옵션 생성
|
|
221
|
-
const cookieOptions = {
|
|
222
|
-
httpOnly: true,
|
|
223
|
-
secure: isProduction,
|
|
224
|
-
sameSite: isProduction ? 'none' : 'lax',
|
|
225
|
-
path: '/',
|
|
226
|
-
maxAge: 15 * 60, // access token: 15분
|
|
227
|
-
};
|
|
228
|
-
if (cookieDomain) {
|
|
229
|
-
cookieOptions.domain = cookieDomain;
|
|
230
|
-
}
|
|
231
|
-
// access_token 설정
|
|
232
|
-
const accessTokenName = `${cookiePrefix}_access_token`;
|
|
233
|
-
response.cookies.delete(accessTokenName);
|
|
234
|
-
response.cookies.set(accessTokenName, accessToken, cookieOptions);
|
|
235
|
-
debugLog('setCustomTokens', `Set ${accessTokenName} cookie:`, {
|
|
236
|
-
hasValue: !!accessToken,
|
|
237
|
-
maxAge: cookieOptions.maxAge,
|
|
238
|
-
domain: cookieOptions.domain,
|
|
239
|
-
secure: cookieOptions.secure,
|
|
240
|
-
sameSite: cookieOptions.sameSite,
|
|
241
|
-
});
|
|
242
|
-
// refresh_token 설정 (있는 경우)
|
|
243
|
-
if (refreshTokenValue) {
|
|
244
|
-
const refreshTokenName = `${cookiePrefix}_refresh_token`;
|
|
245
|
-
const refreshCookieOptions = {
|
|
246
|
-
...cookieOptions,
|
|
247
|
-
maxAge: 30 * 24 * 60 * 60, // refresh token: 30일
|
|
248
|
-
};
|
|
249
|
-
response.cookies.set(refreshTokenName, refreshTokenValue, refreshCookieOptions);
|
|
250
|
-
debugLog('setCustomTokens', `Set ${refreshTokenName} cookie:`, {
|
|
251
|
-
hasValue: !!refreshTokenValue,
|
|
252
|
-
maxAge: refreshCookieOptions.maxAge,
|
|
253
|
-
domain: refreshCookieOptions.domain,
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
/**
|
|
258
|
-
* NextAuth 세션 토큰만 설정
|
|
259
|
-
* @param response NextResponse 객체
|
|
260
|
-
* @param sessionToken NextAuth session token
|
|
261
|
-
* @param options 쿠키 설정 옵션
|
|
262
|
-
* @param options.isProduction 프로덕션 환경 여부 (기본값: false)
|
|
263
|
-
* @param options.cookieDomain 쿠키 도메인 (선택)
|
|
264
|
-
*/
|
|
265
|
-
export function setNextAuthToken(response, sessionToken, options = {}) {
|
|
266
|
-
const { isProduction = false, cookieDomain, } = options;
|
|
267
|
-
// createNextAuthCookies와 동일한 로직 사용
|
|
268
|
-
const cookies = createNextAuthCookies({ isProduction, cookieDomain });
|
|
269
|
-
const cookieName = cookies.sessionToken.name;
|
|
270
|
-
response.cookies.delete(cookieName);
|
|
271
|
-
response.cookies.set(cookieName, sessionToken, {
|
|
272
|
-
...cookies.sessionToken.options,
|
|
273
|
-
httpOnly: true,
|
|
274
|
-
maxAge: 30 * 24 * 60 * 60, // 30일
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* 리다이렉트용 HTML 생성
|
|
279
|
-
* @param redirectPath 리다이렉트할 경로
|
|
280
|
-
* @param text 표시할 텍스트 (필수)
|
|
281
|
-
* @returns HTML 문자열
|
|
282
|
-
*/
|
|
283
|
-
export function createRedirectHTML(redirectPath, text) {
|
|
284
|
-
return `
|
|
285
|
-
<!DOCTYPE html>
|
|
286
|
-
<html>
|
|
287
|
-
<head>
|
|
288
|
-
<meta charset="utf-8">
|
|
289
|
-
<title>Redirecting...</title>
|
|
290
|
-
<style>
|
|
291
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
292
|
-
html, body {
|
|
293
|
-
width: 100%;
|
|
294
|
-
height: 100%;
|
|
295
|
-
overflow: hidden;
|
|
296
|
-
}
|
|
297
|
-
body {
|
|
298
|
-
display: flex;
|
|
299
|
-
align-items: flex-start;
|
|
300
|
-
justify-content: flex-start;
|
|
301
|
-
padding-left: 10%;
|
|
302
|
-
padding-top: 20%;
|
|
303
|
-
background: #fff;
|
|
304
|
-
}
|
|
305
|
-
.typing-text {
|
|
306
|
-
font-family: 'Courier New', monospace;
|
|
307
|
-
font-size: 2.5rem;
|
|
308
|
-
font-weight: bold;
|
|
309
|
-
color: #667eea;
|
|
310
|
-
letter-spacing: 0.1em;
|
|
311
|
-
}
|
|
312
|
-
.cursor {
|
|
313
|
-
display: inline-block;
|
|
314
|
-
width: 3px;
|
|
315
|
-
height: 2.5rem;
|
|
316
|
-
background-color: #667eea;
|
|
317
|
-
margin-left: 2px;
|
|
318
|
-
animation: blink 0.7s infinite;
|
|
319
|
-
}
|
|
320
|
-
@keyframes blink {
|
|
321
|
-
0%, 50% { opacity: 1; }
|
|
322
|
-
51%, 100% { opacity: 0; }
|
|
323
|
-
}
|
|
324
|
-
</style>
|
|
325
|
-
</head>
|
|
326
|
-
<body>
|
|
327
|
-
<div class="typing-text">
|
|
328
|
-
<span id="text"></span><span class="cursor"></span>
|
|
329
|
-
</div>
|
|
330
|
-
<script>
|
|
331
|
-
const text = '${text}';
|
|
332
|
-
let index = 0;
|
|
333
|
-
const speed = 100;
|
|
334
|
-
|
|
335
|
-
function type() {
|
|
336
|
-
if (index < text.length) {
|
|
337
|
-
document.getElementById('text').textContent += text.charAt(index);
|
|
338
|
-
index++;
|
|
339
|
-
setTimeout(type, speed);
|
|
340
|
-
} else {
|
|
341
|
-
setTimeout(() => window.location.href = '${redirectPath}', 200);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
type();
|
|
346
|
-
</script>
|
|
347
|
-
</body>
|
|
348
|
-
</html>
|
|
349
|
-
`;
|
|
350
|
-
}
|
|
351
|
-
/**
|
|
352
|
-
* access token과 refresh token을 사용하여 완전한 인증 세션 생성
|
|
353
|
-
* @param accessToken access token
|
|
354
|
-
* @param secret JWT 서명에 사용할 secret key
|
|
355
|
-
* @param options 추가 옵션
|
|
356
|
-
* @param options.req NextRequest 객체 (필수 - URL origin을 위해 필요)
|
|
357
|
-
* @param options.refreshToken refresh token (선택)
|
|
358
|
-
* @param options.redirectPath 리다이렉트할 경로 (HTTP 302 리다이렉트 사용)
|
|
359
|
-
* @param options.text 응답 메시지 텍스트 (선택사항)
|
|
360
|
-
* @param options.cookiePrefix 쿠키 이름 접두사 (필수)
|
|
361
|
-
* @param options.isProduction 프로덕션 환경 여부 (기본값: false)
|
|
362
|
-
* @param options.cookieDomain 쿠키 도메인 (선택)
|
|
363
|
-
* @param options.serviceId 서비스 ID (필수)
|
|
364
|
-
* @param options.licenseKey 라이센스 키 (필수)
|
|
365
|
-
* @returns NextResponse 객체 (리다이렉트 또는 JSON 응답)
|
|
366
|
-
*/
|
|
367
|
-
export async function createAuthResponse(accessToken, secret, options) {
|
|
368
|
-
await checkLicenseKey(options.licenseKey);
|
|
369
|
-
const { req, refreshToken, redirectPath, text, cookiePrefix, isProduction = false, cookieDomain, serviceId, } = options;
|
|
370
|
-
// 1. 토큰 검증
|
|
371
|
-
const tokenResult = await verifyToken(accessToken, secret);
|
|
372
|
-
if (!tokenResult) {
|
|
373
|
-
throw new Error('Invalid token');
|
|
374
|
-
}
|
|
375
|
-
const { payload } = tokenResult;
|
|
376
|
-
// 2. NextAuth JWT 생성
|
|
377
|
-
const jwt = createNextAuthJWT(payload, serviceId);
|
|
378
|
-
// refreshToken 추가
|
|
379
|
-
if (refreshToken) {
|
|
380
|
-
jwt.refreshToken = refreshToken;
|
|
381
|
-
}
|
|
382
|
-
// accessTokenExpires 추가 (15분)
|
|
383
|
-
jwt.accessTokenExpires = Date.now() + (15 * 60 * 1000);
|
|
384
|
-
debugLog('createAuthResponse', 'JWT prepared (NextAuth will create session from custom tokens):', {
|
|
385
|
-
hasId: !!jwt.id,
|
|
386
|
-
hasEmail: !!jwt.email,
|
|
387
|
-
hasRole: !!jwt.role,
|
|
388
|
-
hasRefreshToken: !!jwt.refreshToken,
|
|
389
|
-
});
|
|
390
|
-
// 3. Response 생성 (HTTP 302 리다이렉트 사용)
|
|
391
|
-
const { NextResponse: NextResponseClass } = await getNextServer();
|
|
392
|
-
// redirectPath가 있으면 302 리다이렉트, 없으면 200 OK
|
|
393
|
-
const response = redirectPath
|
|
394
|
-
? NextResponseClass.redirect(new URL(redirectPath, req.url), { status: 302 })
|
|
395
|
-
: NextResponseClass.json({ success: true, message: text || 'Authentication successful' }, { status: 200 });
|
|
396
|
-
// 4. NextAuth 세션 쿠키 생성
|
|
397
|
-
// 주의: NextAuth는 세션 쿠키를 자동으로 관리하므로, 직접 설정하는 것이 문제를 일으킬 수 있습니다.
|
|
398
|
-
// 하지만 미들웨어에서는 NextAuth의 정상 플로우를 사용할 수 없으므로,
|
|
399
|
-
// NextAuth의 encode()와 decode()가 호환되도록 주의해야 합니다.
|
|
400
|
-
const nextAuthCookieName = isProduction
|
|
401
|
-
? '__Secure-next-auth.session-token'
|
|
402
|
-
: 'next-auth.session-token';
|
|
403
|
-
// NextAuth 세션 쿠키 설정을 건너뛰고 커스텀 토큰만 사용
|
|
404
|
-
// NextAuth는 JWT 콜백을 통해 자동으로 세션 쿠키를 생성하므로,
|
|
405
|
-
// 미들웨어에서 직접 설정하면 NextAuth가 디코딩할 수 없는 경우가 있습니다.
|
|
406
|
-
// 따라서 커스텀 토큰만 설정하고, NextAuth 세션은 JWT 콜백에서 처리하도록 합니다.
|
|
407
|
-
debugLog('createAuthResponse', 'Skipping NextAuth session cookie - will be handled by NextAuth JWT callback', {
|
|
408
|
-
name: nextAuthCookieName,
|
|
409
|
-
hasJWT: !!jwt,
|
|
410
|
-
jwtId: jwt?.id,
|
|
411
|
-
});
|
|
412
|
-
// 5. 커스텀 토큰 쿠키 설정
|
|
413
|
-
if (refreshToken) {
|
|
414
|
-
setCustomTokens(response, accessToken, refreshToken, {
|
|
415
|
-
cookiePrefix,
|
|
416
|
-
isProduction,
|
|
417
|
-
cookieDomain,
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
else {
|
|
421
|
-
setCustomTokens(response, accessToken, {
|
|
422
|
-
cookiePrefix,
|
|
423
|
-
isProduction,
|
|
424
|
-
cookieDomain,
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
debugLog('createAuthResponse', 'Custom tokens set successfully');
|
|
428
|
-
console.log('[createAuthResponse] All cookies set:', {
|
|
429
|
-
nextAuthCookie: nextAuthCookieName,
|
|
430
|
-
accessTokenCookie: `${cookiePrefix}_access_token`,
|
|
431
|
-
refreshTokenCookie: refreshToken ? `${cookiePrefix}_refresh_token` : 'none',
|
|
432
|
-
});
|
|
433
|
-
return response;
|
|
434
|
-
}
|
|
435
|
-
// ============================================================================
|
|
436
|
-
// SSO INTEGRATION FUNCTIONS
|
|
437
|
-
// ============================================================================
|
|
438
|
-
/**
|
|
439
|
-
* 서비스 구독 유효성 확인 함수
|
|
440
|
-
* @param services 서비스 정보 배열
|
|
441
|
-
* @param serviceId 확인할 서비스 ID
|
|
442
|
-
* @param ssoBaseURL SSO 서버 기본 URL (필수)
|
|
443
|
-
* @returns 구독 유효성 결과
|
|
444
|
-
*/
|
|
445
|
-
export function validateServiceSubscription(services, serviceId, ssoBaseURL) {
|
|
446
|
-
const filteredServices = services.filter(service => service.serviceId === serviceId);
|
|
447
|
-
if (filteredServices.length === 0) {
|
|
448
|
-
return {
|
|
449
|
-
isValid: false,
|
|
450
|
-
redirectUrl: `${ssoBaseURL}/services/${serviceId}?type=subscription_required`
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
|
-
const service = filteredServices[0];
|
|
454
|
-
if (service.status !== "ACTIVE") {
|
|
455
|
-
return {
|
|
456
|
-
isValid: false,
|
|
457
|
-
redirectUrl: `${ssoBaseURL}/services/${service.serviceId}?type=subscription_required`,
|
|
458
|
-
service
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
return {
|
|
462
|
-
isValid: true,
|
|
463
|
-
service
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
/**
|
|
467
|
-
* SSO 서버에서 refresh token을 사용하여 새로운 access token을 발급받는 함수
|
|
468
|
-
* @param refreshToken refresh token
|
|
469
|
-
* @param options 옵션
|
|
470
|
-
* @param options.ssoBaseURL SSO 서버 기본 URL (필수)
|
|
471
|
-
* @param options.authServiceKey 인증 서비스 키 (기본값: 환경 변수)
|
|
472
|
-
* @returns SSO refresh token 응답
|
|
473
|
-
*/
|
|
474
|
-
export async function refreshSSOToken(refreshToken, options) {
|
|
475
|
-
const { ssoBaseURL, authServiceKey } = options;
|
|
476
|
-
if (!authServiceKey) {
|
|
477
|
-
throw new Error('AUTH_SERVICE_SECRET_KEY not configured');
|
|
478
|
-
}
|
|
479
|
-
const refreshResponse = await fetch(`${ssoBaseURL}/api/sso/refresh`, {
|
|
480
|
-
method: 'POST',
|
|
481
|
-
headers: {
|
|
482
|
-
'Content-Type': 'application/json',
|
|
483
|
-
'x-auth-service-key': authServiceKey,
|
|
484
|
-
},
|
|
485
|
-
body: JSON.stringify({ refreshToken }),
|
|
486
|
-
});
|
|
487
|
-
return await refreshResponse.json();
|
|
488
|
-
}
|
|
489
|
-
/**
|
|
490
|
-
* SSO 서버에서 사용자의 refresh token을 가져오는 함수
|
|
491
|
-
* @param userId 사용자 ID
|
|
492
|
-
* @param accessToken access token
|
|
493
|
-
* @param options 옵션
|
|
494
|
-
* @param options.ssoBaseURL SSO 서버 기본 URL (필수)
|
|
495
|
-
* @param options.authServiceKey 인증 서비스 키 (기본값: 환경 변수)
|
|
496
|
-
* @returns refresh token 또는 null
|
|
497
|
-
*/
|
|
498
|
-
export async function getRefreshTokenFromSSO(userId, accessToken, options) {
|
|
499
|
-
const { ssoBaseURL, authServiceKey } = options;
|
|
500
|
-
if (!authServiceKey) {
|
|
501
|
-
return null;
|
|
502
|
-
}
|
|
503
|
-
try {
|
|
504
|
-
const refreshResponse = await fetch(`${ssoBaseURL}/api/sso/get-refresh-token`, {
|
|
505
|
-
method: 'POST',
|
|
506
|
-
headers: {
|
|
507
|
-
'Content-Type': 'application/json',
|
|
508
|
-
'x-auth-service-key': authServiceKey,
|
|
509
|
-
},
|
|
510
|
-
body: JSON.stringify({
|
|
511
|
-
userId,
|
|
512
|
-
accessToken
|
|
513
|
-
}),
|
|
514
|
-
});
|
|
515
|
-
const refreshResult = await refreshResponse.json();
|
|
516
|
-
if (refreshResponse.ok && refreshResult.success && refreshResult.refreshToken) {
|
|
517
|
-
return refreshResult.refreshToken;
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
catch {
|
|
521
|
-
// Refresh token 실패해도 access token으로 일단 로그인 가능
|
|
522
|
-
}
|
|
523
|
-
return null;
|
|
524
|
-
}
|
|
525
|
-
// ============================================================================
|
|
526
|
-
// TOKEN REFRESH & VERIFICATION FUNCTIONS
|
|
527
|
-
// ============================================================================
|
|
528
|
-
export async function verifyAndRefreshToken(req, secret, options) {
|
|
529
|
-
const { cookiePrefix, serviceId, isProduction, cookieDomain, text, ssoBaseURL, authServiceKey, forceRefresh = false, } = options;
|
|
530
|
-
// 1. access_token 쿠키 확인
|
|
531
|
-
// forceRefresh가 true이면 access token이 있어도 refresh를 시도
|
|
532
|
-
const accessTokenName = `${cookiePrefix}_access_token`;
|
|
533
|
-
const accessToken = req.cookies.get(accessTokenName)?.value;
|
|
534
|
-
if (accessToken && !forceRefresh) {
|
|
535
|
-
try {
|
|
536
|
-
const secretBytes = new TextEncoder().encode(secret);
|
|
537
|
-
const { payload } = await jwtVerify(accessToken, secretBytes);
|
|
538
|
-
if (payload && typeof payload === 'object' && payload.email) {
|
|
539
|
-
return { isValid: true, payload: payload };
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
catch {
|
|
543
|
-
// 토큰 검증 실패
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
// 리프레시 토큰으로 갱신 시도
|
|
547
|
-
const refreshTokenName = `${cookiePrefix}_refresh_token`;
|
|
548
|
-
const refreshToken = req.cookies.get(refreshTokenName)?.value;
|
|
549
|
-
debugLog('verifyAndRefreshToken', 'Checking refresh:', {
|
|
550
|
-
hasRefreshToken: !!refreshToken,
|
|
551
|
-
forceRefresh,
|
|
552
|
-
});
|
|
553
|
-
if (refreshToken) {
|
|
554
|
-
try {
|
|
555
|
-
if (!ssoBaseURL || !authServiceKey) {
|
|
556
|
-
debugLog('verifyAndRefreshToken', 'SSO config missing');
|
|
557
|
-
return { isValid: false, error: 'SSO_CONFIG_MISSING' };
|
|
558
|
-
}
|
|
559
|
-
debugLog('verifyAndRefreshToken', 'Attempting token refresh...');
|
|
560
|
-
const refreshResult = await refreshSSOToken(refreshToken, {
|
|
561
|
-
ssoBaseURL,
|
|
562
|
-
authServiceKey,
|
|
563
|
-
});
|
|
564
|
-
debugLog('verifyAndRefreshToken', 'Refresh result:', {
|
|
565
|
-
success: refreshResult.success,
|
|
566
|
-
hasAccessToken: !!refreshResult.accessToken,
|
|
567
|
-
});
|
|
568
|
-
if (refreshResult.success && refreshResult.accessToken) {
|
|
569
|
-
const newRefreshToken = refreshResult.refreshToken || refreshToken;
|
|
570
|
-
try {
|
|
571
|
-
let payload;
|
|
572
|
-
try {
|
|
573
|
-
const secretBytes = new TextEncoder().encode(secret);
|
|
574
|
-
const { payload: tokenPayload } = await jwtVerify(refreshResult.accessToken, secretBytes);
|
|
575
|
-
if (tokenPayload && typeof tokenPayload === 'object' && tokenPayload.email) {
|
|
576
|
-
payload = tokenPayload;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
catch {
|
|
580
|
-
// 토큰 검증 실패
|
|
581
|
-
}
|
|
582
|
-
debugLog('verifyAndRefreshToken', 'Updating cookies including NextAuth session...');
|
|
583
|
-
// NextResponse.next()를 생성
|
|
584
|
-
const { NextResponse: NextResponseClass } = await getNextServer();
|
|
585
|
-
const response = NextResponseClass.next();
|
|
586
|
-
// NextAuth JWT 생성
|
|
587
|
-
const jwt = createNextAuthJWT(payload, serviceId);
|
|
588
|
-
if (newRefreshToken) {
|
|
589
|
-
jwt.refreshToken = newRefreshToken;
|
|
590
|
-
}
|
|
591
|
-
jwt.accessTokenExpires = Date.now() + (15 * 60 * 1000);
|
|
592
|
-
// NextAuth 세션 쿠키 생성
|
|
593
|
-
// 미들웨어에서는 NextAuth JWT callback이 실행되지 않으므로,
|
|
594
|
-
// refresh 후 NextAuth 세션 쿠키를 직접 설정해야 합니다.
|
|
595
|
-
try {
|
|
596
|
-
const encodedSessionToken = await encodeNextAuthToken(jwt, secret, 30 * 24 * 60 * 60);
|
|
597
|
-
setNextAuthToken(response, encodedSessionToken, {
|
|
598
|
-
isProduction,
|
|
599
|
-
cookieDomain,
|
|
600
|
-
});
|
|
601
|
-
debugLog('verifyAndRefreshToken', 'NextAuth session cookie set successfully', {
|
|
602
|
-
hasJWT: !!jwt,
|
|
603
|
-
jwtId: jwt?.id,
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
|
-
catch (error) {
|
|
607
|
-
debugError('verifyAndRefreshToken', 'Failed to set NextAuth session cookie:', error);
|
|
608
|
-
// NextAuth 세션 쿠키 설정 실패해도 커스텀 토큰은 설정하므로 계속 진행
|
|
609
|
-
}
|
|
610
|
-
// 커스텀 토큰 쿠키 설정
|
|
611
|
-
if (newRefreshToken) {
|
|
612
|
-
setCustomTokens(response, refreshResult.accessToken, newRefreshToken, {
|
|
613
|
-
cookiePrefix,
|
|
614
|
-
isProduction,
|
|
615
|
-
cookieDomain,
|
|
616
|
-
});
|
|
617
|
-
}
|
|
618
|
-
else {
|
|
619
|
-
setCustomTokens(response, refreshResult.accessToken, {
|
|
620
|
-
cookiePrefix,
|
|
621
|
-
isProduction,
|
|
622
|
-
cookieDomain,
|
|
623
|
-
});
|
|
624
|
-
}
|
|
625
|
-
debugLog('verifyAndRefreshToken', 'All cookies updated');
|
|
626
|
-
return { isValid: true, response, payload };
|
|
627
|
-
}
|
|
628
|
-
catch (error) {
|
|
629
|
-
debugError('verifyAndRefreshToken', 'Failed to create auth response:', error);
|
|
630
|
-
return { isValid: false, error: 'SESSION_CREATION_FAILED' };
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
else {
|
|
634
|
-
debugLog('verifyAndRefreshToken', 'Refresh failed, clearing all cookies');
|
|
635
|
-
const { NextResponse: NextResponseClass } = await getNextServer();
|
|
636
|
-
const response = NextResponseClass.next();
|
|
637
|
-
clearAllAuthCookies(response, options.cookiePrefix, options.isProduction);
|
|
638
|
-
return { isValid: false, response, error: 'REFRESH_FAILED' };
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
catch (error) {
|
|
642
|
-
debugError('verifyAndRefreshToken', 'Token refresh error:', error);
|
|
643
|
-
const { NextResponse: NextResponseClass } = await getNextServer();
|
|
644
|
-
const response = NextResponseClass.next();
|
|
645
|
-
clearAllAuthCookies(response, options.cookiePrefix, options.isProduction);
|
|
646
|
-
return { isValid: false, response, error: 'REFRESH_ERROR' };
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
return { isValid: false, error: 'NO_TOKEN' };
|
|
650
|
-
}
|
|
651
|
-
// ============================================================================
|
|
652
|
-
// HELPER FUNCTIONS
|
|
653
|
-
// ============================================================================
|
|
654
|
-
/**
|
|
655
|
-
* 에러 페이지로 리다이렉트하는 헬퍼 함수
|
|
656
|
-
* @param req NextRequest 객체
|
|
657
|
-
* @param code 에러 코드
|
|
658
|
-
* @param message 에러 메시지
|
|
659
|
-
* @param errorPath 에러 페이지 경로 (기본값: '/error')
|
|
660
|
-
* @returns NextResponse 리다이렉트 응답
|
|
661
|
-
*/
|
|
662
|
-
export async function redirectToError(req, code, message, errorPath = '/error') {
|
|
663
|
-
const url = new URL(errorPath, req.url);
|
|
664
|
-
url.searchParams.set('code', code);
|
|
665
|
-
url.searchParams.set('message', message);
|
|
666
|
-
const { NextResponse: NextResponseClass } = await getNextServer();
|
|
667
|
-
return NextResponseClass.redirect(url);
|
|
668
|
-
}
|
|
669
|
-
/**
|
|
670
|
-
* 인증 쿠키를 삭제하는 헬퍼 함수
|
|
671
|
-
* @param response NextResponse 객체
|
|
672
|
-
* @param cookiePrefix 쿠키 이름 접두사 (필수)
|
|
673
|
-
* @returns NextResponse 객체
|
|
674
|
-
*/
|
|
675
|
-
export function clearAuthCookies(response, cookiePrefix) {
|
|
676
|
-
response.cookies.delete(`${cookiePrefix}_access_token`);
|
|
677
|
-
response.cookies.delete(`${cookiePrefix}_refresh_token`);
|
|
678
|
-
return response;
|
|
679
|
-
}
|
|
680
|
-
/**
|
|
681
|
-
* JWT에서 서비스별 역할을 추출하는 헬퍼 함수
|
|
682
|
-
* @param token NextAuth JWT 객체 또는 null
|
|
683
|
-
* @param serviceId 서비스 ID (필수)
|
|
684
|
-
* @returns 추출된 역할 또는 undefined
|
|
685
|
-
*/
|
|
686
|
-
export function getEffectiveRole(token, serviceId) {
|
|
687
|
-
if (!token)
|
|
688
|
-
return undefined;
|
|
689
|
-
// token이 이미 JWT 객체인 경우 role을 직접 사용
|
|
690
|
-
if (token.role) {
|
|
691
|
-
return token.role;
|
|
692
|
-
}
|
|
693
|
-
// services에서 추출
|
|
694
|
-
const services = token.services || [];
|
|
695
|
-
const service = services.find((s) => s.serviceId === serviceId);
|
|
696
|
-
return service?.role;
|
|
697
|
-
}
|
|
698
|
-
/**
|
|
699
|
-
* 구독이 필요한 경로인지 확인하는 헬퍼 함수
|
|
700
|
-
* @param pathname 경로명
|
|
701
|
-
* @param role 사용자 역할
|
|
702
|
-
* @param subscriptionRequiredPaths 구독이 필요한 경로 배열
|
|
703
|
-
* @param systemAdminRole 시스템 관리자 역할 (기본값: 'SYSTEM_ADMIN')
|
|
704
|
-
* @returns 구독이 필요한지 여부
|
|
705
|
-
*/
|
|
706
|
-
export function requiresSubscription(pathname, role, subscriptionRequiredPaths, systemAdminRole = 'SYSTEM_ADMIN') {
|
|
707
|
-
// 시스템 관리자는 구독 확인 제외
|
|
708
|
-
if (role === systemAdminRole) {
|
|
709
|
-
return false;
|
|
710
|
-
}
|
|
711
|
-
// 구독이 필요한 경로인지 확인
|
|
712
|
-
return subscriptionRequiredPaths.some(path => pathname.startsWith(path));
|
|
713
|
-
}
|
|
714
|
-
// ============================================================================
|
|
715
|
-
// NEXTAUTH CONFIGURATION FUNCTIONS
|
|
716
|
-
// ============================================================================
|
|
717
|
-
// 유효한 라이센스 키 해시 목록
|
|
718
|
-
const VALID_LICENSE_KEY_HASHES = new Set([
|
|
719
|
-
'73bce4f3b64804c255cdab450d759a8b53038f9edb59ae42d9988b08dfd007e2',
|
|
720
|
-
]);
|
|
721
|
-
/**
|
|
722
|
-
* NextAuth 쿠키 설정 생성
|
|
723
|
-
* @param options 옵션
|
|
724
|
-
* @param options.isProduction 프로덕션 환경 여부
|
|
725
|
-
* @param options.cookieDomain 쿠키 도메인 (선택)
|
|
726
|
-
* @returns NextAuth 쿠키 설정 객체
|
|
727
|
-
*/
|
|
728
|
-
export function createNextAuthCookies(options) {
|
|
729
|
-
const { isProduction = false, cookieDomain } = options;
|
|
730
|
-
const isSecure = isProduction;
|
|
731
|
-
// cookieDomain이 설정되어 있으면 같은 도메인/서브도메인 간 쿠키 공유를 위해 'lax' 사용
|
|
732
|
-
// cookieDomain이 없고 프로덕션 환경이면 크로스 도메인을 위해 'none' 사용
|
|
733
|
-
// 개발 환경이면 항상 'lax' 사용
|
|
734
|
-
const sameSiteValue = cookieDomain ? 'lax' : (isSecure ? 'none' : 'lax');
|
|
735
|
-
return {
|
|
736
|
-
sessionToken: {
|
|
737
|
-
name: isSecure ? `__Secure-next-auth.session-token` : `next-auth.session-token`,
|
|
738
|
-
options: {
|
|
739
|
-
httpOnly: true,
|
|
740
|
-
sameSite: sameSiteValue,
|
|
741
|
-
path: '/',
|
|
742
|
-
secure: isSecure,
|
|
743
|
-
...(cookieDomain && { domain: cookieDomain }),
|
|
744
|
-
},
|
|
745
|
-
},
|
|
746
|
-
callbackUrl: {
|
|
747
|
-
name: isSecure ? `__Secure-next-auth.callback-url` : `next-auth.callback-url`,
|
|
748
|
-
options: {
|
|
749
|
-
sameSite: sameSiteValue,
|
|
750
|
-
path: '/',
|
|
751
|
-
secure: isSecure,
|
|
752
|
-
...(cookieDomain && { domain: cookieDomain }),
|
|
753
|
-
},
|
|
754
|
-
},
|
|
755
|
-
csrfToken: {
|
|
756
|
-
name: isSecure ? `__Secure-next-auth.csrf-token` : `next-auth.csrf-token`,
|
|
757
|
-
options: {
|
|
758
|
-
httpOnly: true,
|
|
759
|
-
sameSite: sameSiteValue,
|
|
760
|
-
path: '/',
|
|
761
|
-
secure: isSecure,
|
|
762
|
-
...(cookieDomain && { domain: cookieDomain }),
|
|
763
|
-
},
|
|
764
|
-
},
|
|
765
|
-
};
|
|
766
|
-
}
|
|
767
|
-
/**
|
|
768
|
-
* NextAuth 기본 설정 생성
|
|
769
|
-
* @param options 옵션
|
|
770
|
-
* @param options.secret NextAuth secret
|
|
771
|
-
* @param options.isProduction 프로덕션 환경 여부
|
|
772
|
-
* @param options.cookieDomain 쿠키 도메인 (선택)
|
|
773
|
-
* @param options.signInPath 로그인 페이지 경로 (기본값: '/login')
|
|
774
|
-
* @param options.errorPath 에러 페이지 경로 (기본값: '/login')
|
|
775
|
-
* @param options.nextAuthUrl NextAuth URL (선택)
|
|
776
|
-
* @param options.sessionMaxAge 세션 최대 유지 시간 (초, 기본값: 30일)
|
|
777
|
-
* @param options.jwtMaxAge JWT 최대 유지 시간 (초, 기본값: 30일)
|
|
778
|
-
* @returns NextAuth 기본 설정 객체
|
|
779
|
-
*/
|
|
780
|
-
export function createNextAuthBaseConfig(options) {
|
|
781
|
-
const { secret, isProduction = false, cookieDomain, signInPath = '/login', errorPath = '/login', nextAuthUrl, sessionMaxAge = 30 * 24 * 60 * 60, // 30일
|
|
782
|
-
jwtMaxAge = 30 * 24 * 60 * 60, // 30일
|
|
783
|
-
} = options;
|
|
784
|
-
return {
|
|
785
|
-
session: {
|
|
786
|
-
strategy: 'jwt',
|
|
787
|
-
maxAge: sessionMaxAge,
|
|
788
|
-
},
|
|
789
|
-
jwt: {
|
|
790
|
-
maxAge: jwtMaxAge,
|
|
791
|
-
},
|
|
792
|
-
providers: [],
|
|
793
|
-
...(nextAuthUrl && { url: nextAuthUrl }),
|
|
794
|
-
pages: {
|
|
795
|
-
signIn: signInPath,
|
|
796
|
-
error: errorPath,
|
|
797
|
-
},
|
|
798
|
-
cookies: createNextAuthCookies({ isProduction, cookieDomain }),
|
|
799
|
-
secret,
|
|
800
|
-
};
|
|
801
|
-
}
|
|
802
|
-
/**
|
|
803
|
-
* JWT 콜백에서 초기 로그인 시 토큰 생성 헬퍼
|
|
804
|
-
* @param token 기존 토큰
|
|
805
|
-
* @param user 사용자 정보
|
|
806
|
-
* @param account 계정 정보
|
|
807
|
-
* @returns 업데이트된 JWT 토큰
|
|
808
|
-
*/
|
|
809
|
-
export function createInitialJWTToken(token, user, account) {
|
|
810
|
-
return {
|
|
811
|
-
...token,
|
|
812
|
-
id: user.id,
|
|
813
|
-
email: user.email ?? undefined,
|
|
814
|
-
emailHash: user.emailHash ?? undefined,
|
|
815
|
-
maskedEmail: user.maskedEmail ?? undefined,
|
|
816
|
-
phoneHash: user.phoneHash ?? undefined,
|
|
817
|
-
maskedPhone: user.maskedPhone ?? undefined,
|
|
818
|
-
role: user.role,
|
|
819
|
-
phone: user.phone ?? undefined,
|
|
820
|
-
decryptedEmail: user.decryptedEmail ?? undefined,
|
|
821
|
-
decryptedPhone: user.decryptedPhone ?? undefined,
|
|
822
|
-
refreshToken: user.refreshToken ?? undefined,
|
|
823
|
-
accessTokenExpires: Date.now() + (15 * 60 * 1000), // 15분
|
|
824
|
-
serviceId: account?.serviceId ?? undefined,
|
|
825
|
-
};
|
|
826
|
-
}
|
|
827
|
-
/**
|
|
828
|
-
* Session 콜백에서 빈 세션 반환 헬퍼
|
|
829
|
-
* @param session 기존 세션
|
|
830
|
-
* @returns 빈 세션 객체
|
|
831
|
-
*/
|
|
832
|
-
export function createEmptySession(session) {
|
|
833
|
-
return {
|
|
834
|
-
...session,
|
|
835
|
-
user: {
|
|
836
|
-
...session.user,
|
|
837
|
-
id: '',
|
|
838
|
-
email: null,
|
|
839
|
-
role: 'GUEST',
|
|
840
|
-
},
|
|
841
|
-
expires: new Date().toISOString(),
|
|
842
|
-
};
|
|
843
|
-
}
|
|
844
|
-
/**
|
|
845
|
-
* Session 콜백에서 토큰 정보를 세션에 매핑하는 헬퍼
|
|
846
|
-
* @param session 기존 세션
|
|
847
|
-
* @param token JWT 토큰
|
|
848
|
-
* @returns 업데이트된 세션
|
|
849
|
-
*/
|
|
850
|
-
export function mapTokenToSession(session, token) {
|
|
851
|
-
if (!session.user) {
|
|
852
|
-
return session;
|
|
853
|
-
}
|
|
854
|
-
const user = session.user;
|
|
855
|
-
user.id = token.id;
|
|
856
|
-
user.email = token.email;
|
|
857
|
-
user.name = token.name;
|
|
858
|
-
user.role = token.role;
|
|
859
|
-
user.smsVerified = token.smsVerified;
|
|
860
|
-
user.emailVerified = token.emailVerified;
|
|
861
|
-
user.phone = token.phone;
|
|
862
|
-
user.phoneVerified = token.phoneVerified;
|
|
863
|
-
user.isPasswordReset = token.isPasswordReset || false;
|
|
864
|
-
user.decryptedEmail = token.decryptedEmail;
|
|
865
|
-
user.decryptedPhone = token.decryptedPhone;
|
|
866
|
-
return session;
|
|
867
|
-
}
|
|
868
|
-
/**
|
|
869
|
-
* JWT 콜백을 위한 통합 헬퍼 함수
|
|
870
|
-
* 초기 로그인, 토큰 갱신, 커스텀 토큰 읽기를 모두 처리
|
|
871
|
-
* @param token 기존 JWT 토큰
|
|
872
|
-
* @param user 사용자 정보 (초기 로그인 시)
|
|
873
|
-
* @param account 계정 정보 (초기 로그인 시)
|
|
874
|
-
* @param options 옵션
|
|
875
|
-
* @param options.secret NextAuth secret (커스텀 토큰 읽기용)
|
|
876
|
-
* @param options.licenseKey 라이센스 키 (커스텀 토큰 읽기용)
|
|
877
|
-
* @param options.serviceId 서비스 ID (커스텀 토큰 읽기용)
|
|
878
|
-
* @param options.cookieName 커스텀 토큰 쿠키 이름 (기본값: '{serviceId}_access_token')
|
|
879
|
-
* @param options.debug 디버깅 로그 출력 여부 (기본값: false)
|
|
880
|
-
* @returns 업데이트된 JWT 토큰
|
|
881
|
-
*/
|
|
882
|
-
export async function handleJWTCallback(token, user, account, options) {
|
|
883
|
-
const { secret, licenseKey, serviceId, cookieName, debug = false, ssoBaseURL, authServiceKey, } = options || {};
|
|
884
|
-
// 디버깅 로그
|
|
885
|
-
if (debug) {
|
|
886
|
-
debugLog('handleJWTCallback', 'Token received:', {
|
|
887
|
-
hasId: !!token.id,
|
|
888
|
-
hasEmail: !!token.email,
|
|
889
|
-
hasRole: !!token.role,
|
|
890
|
-
hasExpires: !!token.accessTokenExpires,
|
|
891
|
-
});
|
|
892
|
-
}
|
|
893
|
-
// 1. 초기 로그인 시 (providers를 통한 로그인)
|
|
894
|
-
if (account && user) {
|
|
895
|
-
debugLog('handleJWTCallback', 'Initial login, creating token from user data');
|
|
896
|
-
return createInitialJWTToken(token, user, account);
|
|
897
|
-
}
|
|
898
|
-
// 2. 커스텀 토큰 쿠키 우선 체크 (middleware에서 refresh한 토큰이 있을 수 있음)
|
|
899
|
-
if (secret && licenseKey && serviceId) {
|
|
900
|
-
const cookieNameToUse = cookieName || `${serviceId}_access_token`;
|
|
901
|
-
debugLog('handleJWTCallback', 'Checking custom token cookie first:', cookieNameToUse);
|
|
902
|
-
const customJwt = await getJWTFromCustomTokenCookie(cookieNameToUse, secret, serviceId, licenseKey);
|
|
903
|
-
if (customJwt) {
|
|
904
|
-
debugLog('handleJWTCallback', 'Found valid custom token cookie, using it');
|
|
905
|
-
// refreshToken이 있으면 유지
|
|
906
|
-
if (token.refreshToken) {
|
|
907
|
-
customJwt.refreshToken = token.refreshToken;
|
|
908
|
-
}
|
|
909
|
-
return customJwt;
|
|
910
|
-
}
|
|
911
|
-
debugLog('handleJWTCallback', 'No valid custom token cookie found');
|
|
912
|
-
}
|
|
913
|
-
// 3. 토큰 유효성 체크
|
|
914
|
-
const now = Date.now();
|
|
915
|
-
const expires = token.accessTokenExpires;
|
|
916
|
-
const hasValidToken = token.id && expires && expires > now;
|
|
917
|
-
const refreshToken = token.refreshToken;
|
|
918
|
-
debugLog('handleJWTCallback', 'Token status:', {
|
|
919
|
-
hasId: !!token.id,
|
|
920
|
-
hasExpires: !!expires,
|
|
921
|
-
expiresIn: expires ? Math.round((expires - now) / 1000) + 's' : 'N/A',
|
|
922
|
-
hasValidToken,
|
|
923
|
-
hasRefreshToken: !!refreshToken,
|
|
924
|
-
});
|
|
925
|
-
// 3-1. nextauth token이 있고 만료되지 않았으면 그대로 사용
|
|
926
|
-
if (hasValidToken) {
|
|
927
|
-
debugLog('handleJWTCallback', 'Token is still valid, using existing token');
|
|
928
|
-
return token;
|
|
929
|
-
}
|
|
930
|
-
// 3-2. nextauth token이 없거나 만료됨 → refresh token으로 갱신 시도
|
|
931
|
-
// (refreshToken이 있고 SSO 설정이 있을 때만)
|
|
932
|
-
if (refreshToken && ssoBaseURL && authServiceKey && secret) {
|
|
933
|
-
debugLog('handleJWTCallback', 'Token invalid or expired, attempting SSO refresh');
|
|
934
|
-
try {
|
|
935
|
-
debugLog('handleJWTCallback', 'Calling SSO refresh endpoint:', `${ssoBaseURL}/api/sso/refresh`);
|
|
936
|
-
const response = await fetch(`${ssoBaseURL}/api/sso/refresh`, {
|
|
937
|
-
method: 'POST',
|
|
938
|
-
headers: {
|
|
939
|
-
'Content-Type': 'application/json',
|
|
940
|
-
'x-auth-service-key': authServiceKey,
|
|
941
|
-
},
|
|
942
|
-
body: JSON.stringify({ refreshToken }),
|
|
943
|
-
});
|
|
944
|
-
debugLog('handleJWTCallback', 'SSO refresh response status:', response.status);
|
|
945
|
-
if (response.ok) {
|
|
946
|
-
const result = await response.json();
|
|
947
|
-
if (result.success && result.accessToken) {
|
|
948
|
-
debugLog('handleJWTCallback', 'Successfully refreshed token from SSO');
|
|
949
|
-
// 새 액세스 토큰 검증 및 페이로드 추출
|
|
950
|
-
const tokenResult = await verifyToken(result.accessToken, secret);
|
|
951
|
-
if (tokenResult) {
|
|
952
|
-
const newJWT = createNextAuthJWT(tokenResult.payload, serviceId || '');
|
|
953
|
-
return {
|
|
954
|
-
...newJWT,
|
|
955
|
-
refreshToken, // 기존 refresh token 유지
|
|
956
|
-
accessTokenExpires: Date.now() + (15 * 60 * 1000), // 15분
|
|
957
|
-
};
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
debugLog('handleJWTCallback', 'Failed to refresh token from SSO');
|
|
962
|
-
}
|
|
963
|
-
catch (error) {
|
|
964
|
-
console.error('[handleJWTCallback] Error refreshing token:', error);
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
else {
|
|
968
|
-
debugLog('handleJWTCallback', 'Cannot refresh - missing requirements:', {
|
|
969
|
-
hasRefreshToken: !!refreshToken,
|
|
970
|
-
hasSSO: !!ssoBaseURL,
|
|
971
|
-
hasAuthKey: !!authServiceKey,
|
|
972
|
-
hasSecret: !!secret,
|
|
973
|
-
});
|
|
974
|
-
}
|
|
975
|
-
// 4. refresh 실패 시 - 기존 토큰이 있으면 반환
|
|
976
|
-
if (token.id) {
|
|
977
|
-
debugLog('handleJWTCallback', 'Refresh failed, returning existing token (possibly expired)');
|
|
978
|
-
return token;
|
|
979
|
-
}
|
|
980
|
-
// 5. 모든 시도 실패 - 빈 토큰 반환
|
|
981
|
-
debugLog('handleJWTCallback', 'All attempts failed, returning empty token');
|
|
982
|
-
return token;
|
|
983
|
-
}
|
|
984
|
-
/**
|
|
985
|
-
* 쿠키에서 커스텀 토큰을 읽어서 NextAuth JWT로 변환하는 헬퍼 함수
|
|
986
|
-
* NextAuth JWT 콜백에서 사용
|
|
987
|
-
* @param cookieName 쿠키 이름 (예: 'checkon_access_token')
|
|
988
|
-
* @param secret JWT 서명에 사용할 secret key
|
|
989
|
-
* @param serviceId 서비스 ID (필수)
|
|
990
|
-
* @param licenseKey 라이센스 키 (필수)
|
|
991
|
-
* @returns NextAuth JWT 객체 또는 null
|
|
992
|
-
*/
|
|
993
|
-
export async function getJWTFromCustomTokenCookie(cookieName, secret, serviceId, licenseKey) {
|
|
994
|
-
debugLog('getJWTFromCustomTokenCookie', `Reading cookie: ${cookieName}`);
|
|
995
|
-
try {
|
|
996
|
-
const { cookies } = await import('next/headers');
|
|
997
|
-
const cookieStore = await cookies();
|
|
998
|
-
const accessToken = cookieStore.get(cookieName)?.value;
|
|
999
|
-
if (!accessToken) {
|
|
1000
|
-
debugLog('getJWTFromCustomTokenCookie', 'No access token found in cookies');
|
|
1001
|
-
return null;
|
|
1002
|
-
}
|
|
1003
|
-
await checkLicenseKey(licenseKey);
|
|
1004
|
-
const tokenResult = await verifyToken(accessToken, secret);
|
|
1005
|
-
if (!tokenResult) {
|
|
1006
|
-
debugLog('getJWTFromCustomTokenCookie', 'Token verification failed');
|
|
1007
|
-
return null;
|
|
1008
|
-
}
|
|
1009
|
-
const { payload } = tokenResult;
|
|
1010
|
-
const jwt = createNextAuthJWT(payload, serviceId);
|
|
1011
|
-
// accessTokenExpires 추가 (15분)
|
|
1012
|
-
jwt.accessTokenExpires = Date.now() + (15 * 60 * 1000);
|
|
1013
|
-
// refreshToken 읽기 (쿠키에서)
|
|
1014
|
-
const refreshTokenCookieName = cookieName.replace('_access_token', '_refresh_token');
|
|
1015
|
-
const refreshToken = cookieStore.get(refreshTokenCookieName)?.value;
|
|
1016
|
-
if (refreshToken) {
|
|
1017
|
-
jwt.refreshToken = refreshToken;
|
|
1018
|
-
}
|
|
1019
|
-
debugLog('getJWTFromCustomTokenCookie', 'JWT created successfully from custom token', {
|
|
1020
|
-
hasAccessTokenExpires: !!jwt.accessTokenExpires,
|
|
1021
|
-
hasRefreshToken: !!jwt.refreshToken,
|
|
1022
|
-
});
|
|
1023
|
-
return jwt;
|
|
1024
|
-
}
|
|
1025
|
-
catch (error) {
|
|
1026
|
-
debugError('getJWTFromCustomTokenCookie', `Failed to read ${cookieName}:`, error);
|
|
1027
|
-
return null;
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
// ============================================================================
|
|
1031
|
-
// LICENSE & AUTHORIZATION FUNCTIONS
|
|
1032
|
-
// ============================================================================
|
|
1033
|
-
export async function checkLicenseKey(licenseKey) {
|
|
1034
|
-
if (!licenseKey || licenseKey.length < 10) {
|
|
1035
|
-
throw new Error('License key is required');
|
|
1036
|
-
}
|
|
1037
|
-
const keyHash = await createHashSHA256(licenseKey);
|
|
1038
|
-
if (!VALID_LICENSE_KEY_HASHES.has(keyHash)) {
|
|
1039
|
-
throw new Error('Invalid license key');
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
export function checkRoleAccess(pathname, role, roleConfig) {
|
|
1043
|
-
// 각 역할 설정을 확인
|
|
1044
|
-
for (const [configRole, config] of Object.entries(roleConfig)) {
|
|
1045
|
-
const isPathMatch = config.paths.some(path => pathname.startsWith(path));
|
|
1046
|
-
if (isPathMatch) {
|
|
1047
|
-
// 역할이 정확히 일치하거나 allowedRoles에 포함되어 있으면 허용
|
|
1048
|
-
if (role === configRole || config.allowedRoles?.includes(role)) {
|
|
1049
|
-
return { allowed: true };
|
|
1050
|
-
}
|
|
1051
|
-
// 접근 거부
|
|
1052
|
-
return { allowed: false, message: config.message };
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
// 매칭되는 경로가 없으면 허용
|
|
1056
|
-
return { allowed: true };
|
|
1057
|
-
}
|
|
1058
|
-
// ============================================================================
|
|
1059
|
-
// REDIRECT FUNCTIONS
|
|
1060
|
-
// ============================================================================
|
|
1061
|
-
/**
|
|
1062
|
-
* SSO 로그인 페이지로 리다이렉트하는 헬퍼 함수
|
|
1063
|
-
* @param req NextRequest 객체
|
|
1064
|
-
* @param serviceId 서비스 ID
|
|
1065
|
-
* @param ssoBaseURL SSO 서버 기본 URL (필수)
|
|
1066
|
-
* @returns NextResponse 리다이렉트 응답
|
|
1067
|
-
*/
|
|
1068
|
-
export async function redirectToSSOLogin(req, serviceId, ssoBaseURL) {
|
|
1069
|
-
const { NextResponse: NextResponseClass } = await getNextServer();
|
|
1070
|
-
return NextResponseClass.redirect(new URL(`${ssoBaseURL}/auth/login?serviceId=${serviceId}`, req.url));
|
|
1071
|
-
}
|
|
1072
|
-
/**
|
|
1073
|
-
* 역할별 대시보드 경로로 리다이렉트하는 헬퍼 함수
|
|
1074
|
-
* @param req NextRequest 객체
|
|
1075
|
-
* @param role 사용자 역할
|
|
1076
|
-
* @param rolePaths 역할별 경로 설정 객체
|
|
1077
|
-
* @param defaultPath 기본 경로 (기본값: '/admin')
|
|
1078
|
-
* @returns NextResponse 리다이렉트 응답
|
|
1079
|
-
*/
|
|
1080
|
-
export async function redirectToRoleDashboard(req, role, rolePaths, defaultPath = '/admin') {
|
|
1081
|
-
const redirectPath = rolePaths[role] || defaultPath;
|
|
1082
|
-
const { NextResponse: NextResponseClass } = await getNextServer();
|
|
1083
|
-
return NextResponseClass.redirect(new URL(redirectPath, req.url));
|
|
1084
|
-
}
|
|
1085
|
-
// ============================================================================
|
|
1086
|
-
// TOKEN VALIDATION UTILITIES
|
|
1087
|
-
// ============================================================================
|
|
1088
|
-
/**
|
|
1089
|
-
* 토큰이 만료되었는지 확인하는 함수
|
|
1090
|
-
* @param token NextAuth JWT 객체
|
|
1091
|
-
* @returns 만료 여부
|
|
1092
|
-
*/
|
|
1093
|
-
export function isTokenExpired(token) {
|
|
1094
|
-
if (!token)
|
|
1095
|
-
return true;
|
|
1096
|
-
if (token.exp && typeof token.exp === 'number' && token.exp < Math.floor(Date.now() / 1000)) {
|
|
1097
|
-
return true;
|
|
1098
|
-
}
|
|
1099
|
-
return false;
|
|
1100
|
-
}
|
|
1101
|
-
/**
|
|
1102
|
-
* 토큰이 유효한지 확인하는 함수 (만료 및 필수 필드 체크)
|
|
1103
|
-
* @param token NextAuth JWT 객체
|
|
1104
|
-
* @returns 유효성 여부
|
|
1105
|
-
*/
|
|
1106
|
-
export function isValidToken(token) {
|
|
1107
|
-
if (!token)
|
|
1108
|
-
return false;
|
|
1109
|
-
if (isTokenExpired(token))
|
|
1110
|
-
return false;
|
|
1111
|
-
if (!token.email || !token.id)
|
|
1112
|
-
return false;
|
|
1113
|
-
return true;
|
|
1114
|
-
}
|
|
1115
|
-
/**
|
|
1116
|
-
* 특정 역할을 가지고 있는지 확인하는 함수
|
|
1117
|
-
* @param token NextAuth JWT 객체
|
|
1118
|
-
* @param role 확인할 역할
|
|
1119
|
-
* @param serviceId 서비스 ID (필수)
|
|
1120
|
-
* @returns 역할 보유 여부
|
|
1121
|
-
*/
|
|
1122
|
-
export function hasRole(token, role, serviceId) {
|
|
1123
|
-
if (!token)
|
|
1124
|
-
return false;
|
|
1125
|
-
const effectiveRole = getEffectiveRole(token, serviceId);
|
|
1126
|
-
return effectiveRole === role;
|
|
1127
|
-
}
|
|
1128
|
-
/**
|
|
1129
|
-
* 여러 역할 중 하나라도 가지고 있는지 확인하는 함수
|
|
1130
|
-
* @param token NextAuth JWT 객체
|
|
1131
|
-
* @param roles 확인할 역할 배열
|
|
1132
|
-
* @param serviceId 서비스 ID (필수)
|
|
1133
|
-
* @returns 역할 보유 여부
|
|
1134
|
-
*/
|
|
1135
|
-
export function hasAnyRole(token, roles, serviceId) {
|
|
1136
|
-
if (!token)
|
|
1137
|
-
return false;
|
|
1138
|
-
const effectiveRole = getEffectiveRole(token, serviceId);
|
|
1139
|
-
return roles.includes(effectiveRole || '');
|
|
1140
|
-
}
|
|
1141
|
-
// ============================================================================
|
|
1142
|
-
// PATH UTILITIES
|
|
1143
|
-
// ============================================================================
|
|
1144
|
-
/**
|
|
1145
|
-
* 공개 경로인지 확인하는 함수
|
|
1146
|
-
* @param pathname 경로명
|
|
1147
|
-
* @param publicPaths 공개 경로 배열
|
|
1148
|
-
* @returns 공개 경로 여부
|
|
1149
|
-
*/
|
|
1150
|
-
export function isPublicPath(pathname, publicPaths) {
|
|
1151
|
-
return publicPaths.some(path => pathname === path || pathname.startsWith(path));
|
|
1152
|
-
}
|
|
1153
|
-
/**
|
|
1154
|
-
* API 경로인지 확인하는 함수
|
|
1155
|
-
* @param pathname 경로명
|
|
1156
|
-
* @returns API 경로 여부
|
|
1157
|
-
*/
|
|
1158
|
-
export function isApiPath(pathname) {
|
|
1159
|
-
return pathname.startsWith('/api/');
|
|
1160
|
-
}
|
|
1161
|
-
/**
|
|
1162
|
-
* 보호된 API 경로인지 확인하는 함수
|
|
1163
|
-
* @param pathname 경로명
|
|
1164
|
-
* @param exemptPaths 제외할 경로 배열
|
|
1165
|
-
* @returns 보호된 API 경로 여부
|
|
1166
|
-
*/
|
|
1167
|
-
export function isProtectedApiPath(pathname, exemptPaths = []) {
|
|
1168
|
-
if (!isApiPath(pathname))
|
|
1169
|
-
return false;
|
|
1170
|
-
return !exemptPaths.some(path => pathname.startsWith(path));
|
|
1171
|
-
}
|
|
1172
|
-
// ============================================================================
|
|
1173
|
-
// AUTHENTICATION CHECK FUNCTIONS
|
|
1174
|
-
// ============================================================================
|
|
1175
|
-
/**
|
|
1176
|
-
* NextAuth 토큰과 자체 토큰을 모두 확인하는 통합 인증 체크 함수
|
|
1177
|
-
* @param req NextRequest 객체
|
|
1178
|
-
* @param secret JWT 서명에 사용할 secret key
|
|
1179
|
-
* @param options 옵션
|
|
1180
|
-
* @returns 인증 결과
|
|
1181
|
-
*/
|
|
1182
|
-
export async function checkAuthentication(req, secret, options) {
|
|
1183
|
-
const { cookiePrefix, serviceId, getNextAuthToken, } = options;
|
|
1184
|
-
let nextAuthToken = null;
|
|
1185
|
-
if (getNextAuthToken) {
|
|
1186
|
-
nextAuthToken = await getNextAuthToken(req);
|
|
1187
|
-
}
|
|
1188
|
-
if (nextAuthToken && isValidToken(nextAuthToken)) {
|
|
1189
|
-
return {
|
|
1190
|
-
isAuthenticated: true,
|
|
1191
|
-
token: nextAuthToken,
|
|
1192
|
-
};
|
|
1193
|
-
}
|
|
1194
|
-
const authCheck = await verifyAndRefreshToken(req, secret, options);
|
|
1195
|
-
if (authCheck.response) {
|
|
1196
|
-
return {
|
|
1197
|
-
isAuthenticated: true,
|
|
1198
|
-
response: authCheck.response,
|
|
1199
|
-
payload: authCheck.payload,
|
|
1200
|
-
};
|
|
1201
|
-
}
|
|
1202
|
-
if (authCheck.isValid && authCheck.payload) {
|
|
1203
|
-
return {
|
|
1204
|
-
isAuthenticated: true,
|
|
1205
|
-
payload: authCheck.payload,
|
|
1206
|
-
};
|
|
1207
|
-
}
|
|
1208
|
-
return {
|
|
1209
|
-
isAuthenticated: false,
|
|
1210
|
-
error: authCheck.error || 'NO_TOKEN',
|
|
1211
|
-
};
|
|
1212
|
-
}
|
|
1213
|
-
/**
|
|
1214
|
-
* NextAuth 토큰과 자체 토큰을 모두 확인하는 미들웨어용 함수
|
|
1215
|
-
* NextAuth 토큰이 있으면 바로 통과, 없으면 자체 토큰을 확인
|
|
1216
|
-
* @param req NextRequest 객체
|
|
1217
|
-
* @param nextAuthToken NextAuth JWT 토큰 (null 가능)
|
|
1218
|
-
* @param secret JWT 서명에 사용할 secret key
|
|
1219
|
-
* @param options 옵션
|
|
1220
|
-
* @returns 인증 결과
|
|
1221
|
-
*/
|
|
1222
|
-
export async function verifyAndRefreshTokenWithNextAuth(req, nextAuthToken, secret, options) {
|
|
1223
|
-
const { cookiePrefix, isProduction } = options;
|
|
1224
|
-
// NextAuth 세션 토큰 쿠키 확인
|
|
1225
|
-
const nextAuthSessionTokenCookieName = isProduction
|
|
1226
|
-
? '__Secure-next-auth.session-token'
|
|
1227
|
-
: 'next-auth.session-token';
|
|
1228
|
-
const nextAuthCookieValue = req.cookies.get(nextAuthSessionTokenCookieName)?.value;
|
|
1229
|
-
const hasNextAuthSessionTokenCookie = !!nextAuthCookieValue;
|
|
1230
|
-
debugLog('verifyAndRefreshTokenWithNextAuth', 'NextAuth cookie check:', {
|
|
1231
|
-
cookieName: nextAuthSessionTokenCookieName,
|
|
1232
|
-
hasCookie: hasNextAuthSessionTokenCookie,
|
|
1233
|
-
cookieLength: nextAuthCookieValue?.length || 0,
|
|
1234
|
-
cookiePrefix: nextAuthCookieValue?.substring(0, 30) + '...' || 'none',
|
|
1235
|
-
});
|
|
1236
|
-
// NextAuth 토큰 확인
|
|
1237
|
-
const hasValidNextAuthToken = nextAuthToken && isValidToken(nextAuthToken);
|
|
1238
|
-
// Access token 확인
|
|
1239
|
-
const accessTokenName = `${cookiePrefix}_access_token`;
|
|
1240
|
-
const accessToken = req.cookies.get(accessTokenName)?.value;
|
|
1241
|
-
let hasValidAccessToken = false;
|
|
1242
|
-
if (accessToken) {
|
|
1243
|
-
try {
|
|
1244
|
-
const secretBytes = new TextEncoder().encode(secret);
|
|
1245
|
-
const { payload } = await jwtVerify(accessToken, secretBytes);
|
|
1246
|
-
if (payload && typeof payload === 'object' && payload.email) {
|
|
1247
|
-
hasValidAccessToken = true;
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
|
-
catch {
|
|
1251
|
-
// 토큰 검증 실패
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
// Refresh token 확인
|
|
1255
|
-
const refreshTokenName = `${cookiePrefix}_refresh_token`;
|
|
1256
|
-
const refreshToken = req.cookies.get(refreshTokenName)?.value;
|
|
1257
|
-
debugLog('verifyAndRefreshTokenWithNextAuth', 'Token status:', {
|
|
1258
|
-
hasNextAuthCookie: hasNextAuthSessionTokenCookie,
|
|
1259
|
-
hasValidNextAuth: hasValidNextAuthToken,
|
|
1260
|
-
hasValidAccess: hasValidAccessToken,
|
|
1261
|
-
hasRefresh: !!refreshToken,
|
|
1262
|
-
});
|
|
1263
|
-
// NextAuth cookie와 access token이 모두 유효하면 통과
|
|
1264
|
-
if (hasValidNextAuthToken && hasValidAccessToken) {
|
|
1265
|
-
debugLog('verifyAndRefreshTokenWithNextAuth', 'Both NextAuth and access tokens are valid');
|
|
1266
|
-
// payload 추출
|
|
1267
|
-
let payload;
|
|
1268
|
-
if (accessToken) {
|
|
1269
|
-
try {
|
|
1270
|
-
const secretBytes = new TextEncoder().encode(secret);
|
|
1271
|
-
const result = await jwtVerify(accessToken, secretBytes);
|
|
1272
|
-
payload = result.payload;
|
|
1273
|
-
}
|
|
1274
|
-
catch {
|
|
1275
|
-
// 이미 검증됐으므로 실패하지 않을 것
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
return {
|
|
1279
|
-
isValid: true,
|
|
1280
|
-
token: nextAuthToken,
|
|
1281
|
-
payload
|
|
1282
|
-
};
|
|
1283
|
-
}
|
|
1284
|
-
// NextAuth cookie가 없거나 access token이 없으면 refresh 시도
|
|
1285
|
-
if (refreshToken && (!hasValidNextAuthToken || !hasValidAccessToken)) {
|
|
1286
|
-
debugLog('verifyAndRefreshTokenWithNextAuth', 'Missing NextAuth or access token, attempting refresh');
|
|
1287
|
-
const authCheck = await verifyAndRefreshToken(req, secret, {
|
|
1288
|
-
...options,
|
|
1289
|
-
forceRefresh: true,
|
|
1290
|
-
});
|
|
1291
|
-
// refresh 후 NextAuth token 재조회 (새로 생성된 cookie에서)
|
|
1292
|
-
let refreshedToken = null;
|
|
1293
|
-
if (authCheck.isValid && authCheck.payload) {
|
|
1294
|
-
// payload에서 JWT 생성
|
|
1295
|
-
refreshedToken = createNextAuthJWT(authCheck.payload, options.serviceId);
|
|
1296
|
-
}
|
|
1297
|
-
return {
|
|
1298
|
-
...authCheck,
|
|
1299
|
-
token: refreshedToken || undefined
|
|
1300
|
-
};
|
|
1301
|
-
}
|
|
1302
|
-
// 하나라도 유효하면 일단 통과 (refresh token이 없는 경우)
|
|
1303
|
-
if (hasValidNextAuthToken || hasValidAccessToken) {
|
|
1304
|
-
debugLog('verifyAndRefreshTokenWithNextAuth', 'At least one token is valid (no refresh token)');
|
|
1305
|
-
// payload 추출
|
|
1306
|
-
let payload;
|
|
1307
|
-
if (accessToken && hasValidAccessToken) {
|
|
1308
|
-
try {
|
|
1309
|
-
const secretBytes = new TextEncoder().encode(secret);
|
|
1310
|
-
const result = await jwtVerify(accessToken, secretBytes);
|
|
1311
|
-
payload = result.payload;
|
|
1312
|
-
}
|
|
1313
|
-
catch {
|
|
1314
|
-
// 무시
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
return {
|
|
1318
|
-
isValid: true,
|
|
1319
|
-
token: nextAuthToken || (payload ? createNextAuthJWT(payload, options.serviceId) : undefined),
|
|
1320
|
-
payload
|
|
1321
|
-
};
|
|
1322
|
-
}
|
|
1323
|
-
debugLog('verifyAndRefreshTokenWithNextAuth', 'No tokens available');
|
|
1324
|
-
return { isValid: false, error: 'NO_TOKEN' };
|
|
1325
|
-
}
|
|
1326
|
-
// ============================================================================
|
|
1327
|
-
// MIDDLEWARE CONFIGURATION & HANDLER
|
|
1328
|
-
// ============================================================================
|
|
1329
|
-
/**
|
|
1330
|
-
* 기본 미들웨어 설정을 생성하는 함수
|
|
1331
|
-
* @param config 커스텀 설정 (필수: serviceId 포함)
|
|
1332
|
-
* @param defaults 기본 설정값 (선택사항, 제공하지 않으면 최소 기본값 사용)
|
|
1333
|
-
* @returns 미들웨어 설정 객체
|
|
1334
|
-
*/
|
|
1335
|
-
export function createMiddlewareConfig(config, defaults) {
|
|
1336
|
-
// 기본값 설정
|
|
1337
|
-
const defaultPublicPaths = defaults?.publicPaths || [
|
|
1338
|
-
'/robots.txt',
|
|
1339
|
-
'/sitemap.xml',
|
|
1340
|
-
'/ads.txt',
|
|
1341
|
-
'/images',
|
|
1342
|
-
'/login',
|
|
1343
|
-
'/register',
|
|
1344
|
-
'/forgot-password',
|
|
1345
|
-
'/reset-password',
|
|
1346
|
-
'/about',
|
|
1347
|
-
'/pricing',
|
|
1348
|
-
'/support',
|
|
1349
|
-
'/terms',
|
|
1350
|
-
'/privacy',
|
|
1351
|
-
'/refund',
|
|
1352
|
-
'/error',
|
|
1353
|
-
];
|
|
1354
|
-
const defaultSubscriptionRequiredPaths = defaults?.subscriptionRequiredPaths || [
|
|
1355
|
-
'/admin',
|
|
1356
|
-
'/teacher',
|
|
1357
|
-
'/student',
|
|
1358
|
-
'/personal',
|
|
1359
|
-
];
|
|
1360
|
-
const defaultSubscriptionExemptApiPaths = defaults?.subscriptionExemptApiPaths || [
|
|
1361
|
-
'/api/auth',
|
|
1362
|
-
'/api/sso',
|
|
1363
|
-
'/api/admin/subscription',
|
|
1364
|
-
'/api/admin/payment-methods',
|
|
1365
|
-
];
|
|
1366
|
-
const defaultAuthApiPaths = defaults?.authApiPaths || [
|
|
1367
|
-
'/api/auth/send-verification',
|
|
1368
|
-
'/api/auth/verify-code',
|
|
1369
|
-
'/api/auth/verify-user',
|
|
1370
|
-
'/api/auth/select-academy',
|
|
1371
|
-
];
|
|
1372
|
-
const defaultRolePaths = defaults?.rolePaths || {
|
|
1373
|
-
SYSTEM_ADMIN: '/system',
|
|
1374
|
-
ADMIN: '/admin',
|
|
1375
|
-
TEACHER: '/teacher',
|
|
1376
|
-
STUDENT: '/student',
|
|
1377
|
-
};
|
|
1378
|
-
const defaultSystemAdminRole = defaults?.systemAdminRole || 'SYSTEM_ADMIN';
|
|
1379
|
-
const defaultErrorPath = defaults?.errorPath || '/error';
|
|
1380
|
-
// 커스텀 설정으로 병합
|
|
1381
|
-
return {
|
|
1382
|
-
publicPaths: config.publicPaths || defaultPublicPaths,
|
|
1383
|
-
subscriptionRequiredPaths: config.subscriptionRequiredPaths || defaultSubscriptionRequiredPaths,
|
|
1384
|
-
subscriptionExemptApiPaths: config.subscriptionExemptApiPaths || defaultSubscriptionExemptApiPaths,
|
|
1385
|
-
authApiPaths: config.authApiPaths || defaultAuthApiPaths,
|
|
1386
|
-
rolePaths: config.rolePaths || defaultRolePaths,
|
|
1387
|
-
roleAccessConfig: config.roleAccessConfig || {},
|
|
1388
|
-
systemAdminRole: config.systemAdminRole || defaultSystemAdminRole,
|
|
1389
|
-
errorPath: config.errorPath || defaultErrorPath,
|
|
1390
|
-
serviceId: config.serviceId,
|
|
1391
|
-
};
|
|
1392
|
-
}
|
|
1393
|
-
/**
|
|
1394
|
-
* 통합 미들웨어 핸들러 함수
|
|
1395
|
-
* 모든 인증, 권한, 구독 체크를 포함한 완전한 미들웨어 로직
|
|
1396
|
-
* @param req NextRequest 객체
|
|
1397
|
-
* @param config 미들웨어 설정
|
|
1398
|
-
* @param options 미들웨어 실행 옵션 (secret 필수)
|
|
1399
|
-
* @returns NextResponse 또는 null (다음 미들웨어로 진행)
|
|
1400
|
-
*/
|
|
1401
|
-
export async function handleMiddleware(req, config, options) {
|
|
1402
|
-
// Edge Runtime 호환을 위해 next/server를 한 번만 import
|
|
1403
|
-
const { NextResponse: NextResponseClass } = await getNextServer();
|
|
1404
|
-
try {
|
|
1405
|
-
const pathname = req.nextUrl.pathname;
|
|
1406
|
-
const { secret, isProduction, cookieDomain, getNextAuthToken, licenseKey } = options;
|
|
1407
|
-
debugLog('handleMiddleware', `Processing: ${pathname}`);
|
|
1408
|
-
await checkLicenseKey(licenseKey);
|
|
1409
|
-
if (!config.serviceId) {
|
|
1410
|
-
throw new Error('serviceId is required in middleware config');
|
|
1411
|
-
}
|
|
1412
|
-
const serviceId = config.serviceId;
|
|
1413
|
-
const cookiePrefix = serviceId;
|
|
1414
|
-
let token = null;
|
|
1415
|
-
if (getNextAuthToken) {
|
|
1416
|
-
token = await getNextAuthToken(req);
|
|
1417
|
-
debugLog('handleMiddleware', 'Custom getNextAuthToken result:', { hasToken: !!token });
|
|
1418
|
-
}
|
|
1419
|
-
else {
|
|
1420
|
-
try {
|
|
1421
|
-
const { getToken } = await import('next-auth/jwt');
|
|
1422
|
-
token = await getToken({ req, secret });
|
|
1423
|
-
debugLog('handleMiddleware', 'getToken result:', { hasToken: !!token });
|
|
1424
|
-
}
|
|
1425
|
-
catch (error) {
|
|
1426
|
-
debugLog('handleMiddleware', 'getToken failed:', error);
|
|
1427
|
-
// NextAuth가 없으면 null 유지
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
debugLog('handleMiddleware', 'Token status:', {
|
|
1431
|
-
hasToken: !!token,
|
|
1432
|
-
hasId: !!token?.id,
|
|
1433
|
-
});
|
|
1434
|
-
const effectiveRole = getEffectiveRole(token, serviceId);
|
|
1435
|
-
debugLog('handleMiddleware', `Effective role: ${effectiveRole || 'none'}`);
|
|
1436
|
-
// 1. API 요청 처리
|
|
1437
|
-
if (pathname.startsWith('/api/')) {
|
|
1438
|
-
if (config.authApiPaths.includes(pathname)) {
|
|
1439
|
-
return NextResponseClass.next();
|
|
1440
|
-
}
|
|
1441
|
-
if (config.subscriptionExemptApiPaths.some((path) => pathname.startsWith(path))) {
|
|
1442
|
-
return NextResponseClass.next();
|
|
1443
|
-
}
|
|
1444
|
-
const authCheck = await verifyAndRefreshTokenWithNextAuth(req, token, secret, {
|
|
1445
|
-
cookiePrefix,
|
|
1446
|
-
serviceId,
|
|
1447
|
-
isProduction,
|
|
1448
|
-
cookieDomain,
|
|
1449
|
-
text: serviceId,
|
|
1450
|
-
ssoBaseURL: options.ssoBaseURL,
|
|
1451
|
-
authServiceKey: options.authServiceKey,
|
|
1452
|
-
licenseKey: options.licenseKey,
|
|
1453
|
-
});
|
|
1454
|
-
if (authCheck.response) {
|
|
1455
|
-
return authCheck.response;
|
|
1456
|
-
}
|
|
1457
|
-
if (!authCheck.isValid) {
|
|
1458
|
-
const response = await redirectToError(req, 'UNAUTHORIZED', '인증이 필요합니다.', config.errorPath);
|
|
1459
|
-
return clearAuthCookies(response, cookiePrefix);
|
|
1460
|
-
}
|
|
1461
|
-
return NextResponseClass.next();
|
|
1462
|
-
}
|
|
1463
|
-
// 2. 루트 경로 처리 - SSO 토큰 처리 (인증 체크보다 먼저!)
|
|
1464
|
-
if (pathname === '/') {
|
|
1465
|
-
const tokenParam = req.nextUrl.searchParams.get('token');
|
|
1466
|
-
console.log('[handleMiddleware] Root path check:', {
|
|
1467
|
-
pathname,
|
|
1468
|
-
hasTokenParam: !!tokenParam,
|
|
1469
|
-
tokenLength: tokenParam?.length || 0,
|
|
1470
|
-
});
|
|
1471
|
-
if (tokenParam) {
|
|
1472
|
-
debugLog('handleMiddleware', 'Processing SSO token from query parameter');
|
|
1473
|
-
console.log('[handleMiddleware] Processing SSO token from query parameter');
|
|
1474
|
-
try {
|
|
1475
|
-
// 1. 토큰 검증
|
|
1476
|
-
const tokenResult = await verifyToken(tokenParam, secret);
|
|
1477
|
-
if (!tokenResult) {
|
|
1478
|
-
throw new Error('Invalid token');
|
|
1479
|
-
}
|
|
1480
|
-
const { payload } = tokenResult;
|
|
1481
|
-
// 2. 역할 추출
|
|
1482
|
-
const defaultRole = Object.keys(config.rolePaths)[0] || 'ADMIN';
|
|
1483
|
-
const tokenRole = extractRoleFromPayload(payload, serviceId, defaultRole);
|
|
1484
|
-
debugLog('handleMiddleware', `Extracted role: ${tokenRole}`);
|
|
1485
|
-
// 3. Refresh token 가져오기 (서버 간 통신)
|
|
1486
|
-
const userId = payload.id || payload.sub || payload.userId || '';
|
|
1487
|
-
const ssoBaseURL = options.ssoBaseURL;
|
|
1488
|
-
const authServiceKey = options.authServiceKey;
|
|
1489
|
-
debugLog('handleMiddleware', 'Getting refresh token from SSO:', {
|
|
1490
|
-
userId,
|
|
1491
|
-
hasSSO: !!ssoBaseURL,
|
|
1492
|
-
hasAuthKey: !!authServiceKey,
|
|
1493
|
-
});
|
|
1494
|
-
let refreshToken = '';
|
|
1495
|
-
if (authServiceKey && userId) {
|
|
1496
|
-
try {
|
|
1497
|
-
const refreshTokenResult = await getRefreshTokenFromSSO(userId, tokenParam, { ssoBaseURL, authServiceKey });
|
|
1498
|
-
refreshToken = refreshTokenResult || '';
|
|
1499
|
-
debugLog('handleMiddleware', 'Refresh token result:', {
|
|
1500
|
-
hasRefreshToken: !!refreshToken,
|
|
1501
|
-
length: refreshToken.length,
|
|
1502
|
-
});
|
|
1503
|
-
}
|
|
1504
|
-
catch (error) {
|
|
1505
|
-
debugError('handleMiddleware', 'Failed to get refresh token:', error);
|
|
1506
|
-
// refresh token이 없어도 access token으로는 로그인 가능
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
|
-
else {
|
|
1510
|
-
debugLog('handleMiddleware', 'Skipping refresh token fetch:', {
|
|
1511
|
-
hasUserId: !!userId,
|
|
1512
|
-
hasAuthKey: !!authServiceKey,
|
|
1513
|
-
});
|
|
1514
|
-
}
|
|
1515
|
-
// 4. 자체 토큰 생성 및 쿠키 설정
|
|
1516
|
-
const redirectPath = config.rolePaths[tokenRole] || config.rolePaths[defaultRole] || '/admin';
|
|
1517
|
-
debugLog('handleMiddleware', `Creating auth response, redirect to: ${redirectPath}`);
|
|
1518
|
-
console.log('[handleMiddleware] Creating auth response:', {
|
|
1519
|
-
redirectPath,
|
|
1520
|
-
hasRefreshToken: !!refreshToken,
|
|
1521
|
-
cookiePrefix,
|
|
1522
|
-
isProduction,
|
|
1523
|
-
cookieDomain,
|
|
1524
|
-
});
|
|
1525
|
-
const response = await createAuthResponse(tokenParam, secret, {
|
|
1526
|
-
req,
|
|
1527
|
-
refreshToken: refreshToken || undefined,
|
|
1528
|
-
redirectPath,
|
|
1529
|
-
text: serviceId,
|
|
1530
|
-
cookiePrefix,
|
|
1531
|
-
isProduction,
|
|
1532
|
-
cookieDomain,
|
|
1533
|
-
serviceId,
|
|
1534
|
-
licenseKey: options.licenseKey,
|
|
1535
|
-
});
|
|
1536
|
-
console.log('[handleMiddleware] Auth response created, cookies set:', {
|
|
1537
|
-
hasResponse: !!response,
|
|
1538
|
-
cookieNames: ['renton_access_token', 'renton_refresh_token', isProduction ? '__Secure-next-auth.session-token' : 'next-auth.session-token'],
|
|
1539
|
-
});
|
|
1540
|
-
return response;
|
|
1541
|
-
}
|
|
1542
|
-
catch (error) {
|
|
1543
|
-
debugError('handleMiddleware', 'Error processing token:', error);
|
|
1544
|
-
const ssoBaseURL = options.ssoBaseURL;
|
|
1545
|
-
return await redirectToSSOLogin(req, serviceId, ssoBaseURL);
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
// 토큰이 없고 이미 인증된 경우 역할별 대시보드로 리다이렉트
|
|
1549
|
-
if (token && effectiveRole) {
|
|
1550
|
-
return await redirectToRoleDashboard(req, effectiveRole, config.rolePaths);
|
|
1551
|
-
}
|
|
1552
|
-
// 인증되지 않은 경우 SSO 로그인 페이지로 리다이렉트
|
|
1553
|
-
const ssoBaseURL = options.ssoBaseURL;
|
|
1554
|
-
return await redirectToSSOLogin(req, serviceId, ssoBaseURL);
|
|
1555
|
-
}
|
|
1556
|
-
// 3. 공개 경로 처리
|
|
1557
|
-
if (config.publicPaths.some((path) => pathname === path || pathname.startsWith(path))) {
|
|
1558
|
-
if (pathname === '/error' || pathname === '/verification') {
|
|
1559
|
-
return NextResponseClass.next();
|
|
1560
|
-
}
|
|
1561
|
-
if (token && effectiveRole) {
|
|
1562
|
-
return await redirectToRoleDashboard(req, effectiveRole, config.rolePaths);
|
|
1563
|
-
}
|
|
1564
|
-
return NextResponseClass.next();
|
|
1565
|
-
}
|
|
1566
|
-
// 4. 인증 체크
|
|
1567
|
-
const authCheck = await verifyAndRefreshTokenWithNextAuth(req, token, secret, {
|
|
1568
|
-
cookiePrefix,
|
|
1569
|
-
serviceId,
|
|
1570
|
-
isProduction,
|
|
1571
|
-
cookieDomain,
|
|
1572
|
-
text: serviceId,
|
|
1573
|
-
ssoBaseURL: options.ssoBaseURL,
|
|
1574
|
-
authServiceKey: options.authServiceKey,
|
|
1575
|
-
licenseKey: options.licenseKey,
|
|
1576
|
-
});
|
|
1577
|
-
if (authCheck.response) {
|
|
1578
|
-
return authCheck.response;
|
|
1579
|
-
}
|
|
1580
|
-
if (!authCheck.isValid) {
|
|
1581
|
-
const ssoBaseURL = options.ssoBaseURL;
|
|
1582
|
-
return await redirectToSSOLogin(req, serviceId, ssoBaseURL);
|
|
1583
|
-
}
|
|
1584
|
-
// 5. 토큰 확인 - authCheck 결과 재사용 (중복 검증 제거)
|
|
1585
|
-
let finalToken = authCheck.token || token;
|
|
1586
|
-
// authCheck에서 토큰을 반환하지 않았지만 유효한 경우 (드문 케이스)
|
|
1587
|
-
if (!finalToken && authCheck.isValid) {
|
|
1588
|
-
debugLog('handleMiddleware', 'authCheck valid but no token, trying to get NextAuth token');
|
|
1589
|
-
if (getNextAuthToken) {
|
|
1590
|
-
finalToken = await getNextAuthToken(req);
|
|
1591
|
-
}
|
|
1592
|
-
else {
|
|
1593
|
-
try {
|
|
1594
|
-
const { getToken } = await import('next-auth/jwt');
|
|
1595
|
-
finalToken = await getToken({ req, secret });
|
|
1596
|
-
}
|
|
1597
|
-
catch {
|
|
1598
|
-
// NextAuth가 없으면 null 유지
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
if (!finalToken) {
|
|
1603
|
-
const ssoBaseURL = options.ssoBaseURL;
|
|
1604
|
-
return await redirectToSSOLogin(req, serviceId, ssoBaseURL);
|
|
1605
|
-
}
|
|
1606
|
-
// 6. 토큰 에러 체크
|
|
1607
|
-
if (finalToken.error === "RefreshAccessTokenError") {
|
|
1608
|
-
const ssoBaseURL = options.ssoBaseURL;
|
|
1609
|
-
return await redirectToSSOLogin(req, serviceId, ssoBaseURL);
|
|
1610
|
-
}
|
|
1611
|
-
// 7. 토큰 유효성 체크
|
|
1612
|
-
if (!finalToken.role || !finalToken.email) {
|
|
1613
|
-
const ssoBaseURL = options.ssoBaseURL;
|
|
1614
|
-
return await redirectToSSOLogin(req, serviceId, ssoBaseURL);
|
|
1615
|
-
}
|
|
1616
|
-
// 8. 역할 기반 접근 제어
|
|
1617
|
-
const finalEffectiveRole = finalToken.role || getEffectiveRole(finalToken, serviceId) || effectiveRole || '';
|
|
1618
|
-
if (config.roleAccessConfig && Object.keys(config.roleAccessConfig).length > 0 && finalEffectiveRole) {
|
|
1619
|
-
const roleCheck = checkRoleAccess(pathname, finalEffectiveRole, config.roleAccessConfig);
|
|
1620
|
-
if (!roleCheck.allowed) {
|
|
1621
|
-
return await redirectToError(req, 'ACCESS_DENIED', roleCheck.message || '접근 권한이 없습니다.', config.errorPath);
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
// 9. 구독 상태 확인 (시스템 관리자 제외)
|
|
1625
|
-
if (finalEffectiveRole && requiresSubscription(pathname, finalEffectiveRole, config.subscriptionRequiredPaths, config.systemAdminRole)) {
|
|
1626
|
-
const services = finalToken.services || [];
|
|
1627
|
-
const ssoBaseURL = options.ssoBaseURL;
|
|
1628
|
-
if (!ssoBaseURL) {
|
|
1629
|
-
throw new Error('ssoBaseURL is required in middleware options');
|
|
1630
|
-
}
|
|
1631
|
-
const subscriptionCheck = validateServiceSubscription(services, serviceId, ssoBaseURL);
|
|
1632
|
-
if (!subscriptionCheck.isValid) {
|
|
1633
|
-
return NextResponseClass.redirect(subscriptionCheck.redirectUrl);
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
return null;
|
|
1637
|
-
}
|
|
1638
|
-
catch (error) {
|
|
1639
|
-
debugError('handleMiddleware', 'Middleware error:', error);
|
|
1640
|
-
return await redirectToError(req, 'INTERNAL_ERROR', '서버 오류가 발생했습니다.', config.errorPath);
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./types"), exports);
|
|
18
|
+
__exportStar(require("./utils/logger"), exports);
|
|
19
|
+
__exportStar(require("./utils/crypto"), exports);
|
|
20
|
+
__exportStar(require("./utils/server"), exports);
|
|
21
|
+
__exportStar(require("./utils/redirect"), exports);
|
|
22
|
+
__exportStar(require("./utils/path"), exports);
|
|
23
|
+
__exportStar(require("./utils/license"), exports);
|
|
24
|
+
__exportStar(require("./core/jwt"), exports);
|
|
25
|
+
__exportStar(require("./core/cookies"), exports);
|
|
26
|
+
__exportStar(require("./core/auth-response"), exports);
|
|
27
|
+
__exportStar(require("./core/roles"), exports);
|
|
28
|
+
__exportStar(require("./core/verify"), exports);
|
|
29
|
+
__exportStar(require("./sso/api"), exports);
|
|
30
|
+
__exportStar(require("./nextauth/config"), exports);
|
|
31
|
+
__exportStar(require("./nextauth/callbacks"), exports);
|
|
32
|
+
__exportStar(require("./nextauth/session"), exports);
|
|
33
|
+
__exportStar(require("./middleware/config"), exports);
|
|
34
|
+
__exportStar(require("./middleware/handler"), exports);
|