@thinkingcat/auth-utils 1.0.16 → 1.0.18
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 +57 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.js +235 -55
- package/package.json +2 -5
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@ ThinkingCat SSO 서비스를 위한 인증 유틸리티 패키지입니다. JWT
|
|
|
6
6
|
|
|
7
7
|
- [📦 설치 (Installation)](#-설치-installation)
|
|
8
8
|
- [📋 요구사항 (Requirements)](#-요구사항-requirements)
|
|
9
|
+
- [🐛 디버깅 (Debugging)](#-디버깅-debugging)
|
|
9
10
|
- [⚙️ Next.js 설정 (Next.js Configuration)](#️-nextjs-설정-nextjs-configuration)
|
|
10
11
|
- [🚀 빠른 시작 (Quick Start)](#-빠른-시작-quick-start)
|
|
11
12
|
- [📚 주요 기능 (Features)](#-주요-기능-features)
|
|
@@ -47,6 +48,36 @@ pnpm add @thinkingcat/auth-utils
|
|
|
47
48
|
- **Node.js**: >= 18.0.0
|
|
48
49
|
- **TypeScript**: 권장 (타입 지원)
|
|
49
50
|
|
|
51
|
+
## 🐛 디버깅 (Debugging)
|
|
52
|
+
|
|
53
|
+
이 패키지는 조건부 로깅 시스템을 사용합니다. 기본적으로 프로덕션 환경에서는 로그가 출력되지 않으며, 개발 환경에서만 로그가 출력됩니다.
|
|
54
|
+
|
|
55
|
+
### 디버그 로그 활성화
|
|
56
|
+
|
|
57
|
+
환경 변수 `AUTH_UTILS_DEBUG=true`를 설정하여 모든 환경에서 디버그 로그를 활성화할 수 있습니다:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# .env.local 또는 환경 변수 설정
|
|
61
|
+
AUTH_UTILS_DEBUG=true
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 로그 출력 조건
|
|
65
|
+
|
|
66
|
+
- `NODE_ENV === 'development'`: 자동으로 로그 출력
|
|
67
|
+
- `AUTH_UTILS_DEBUG === 'true'`: 모든 환경에서 로그 출력
|
|
68
|
+
- 그 외: 로그 출력 안 함 (성능 최적화)
|
|
69
|
+
|
|
70
|
+
### 디버그 로그 예시
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
// 개발 환경 또는 AUTH_UTILS_DEBUG=true일 때만 출력됨
|
|
74
|
+
[handleMiddleware] Processing: /admin
|
|
75
|
+
[verifyAndRefreshToken] Checking refresh: { hasRefreshToken: true, forceRefresh: false }
|
|
76
|
+
[createAuthResponse] JWT created: { hasId: true, hasEmail: true, hasRole: true }
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
이 기능으로 프로덕션 환경에서 불필요한 로그 출력을 방지하여 성능을 최적화합니다.
|
|
80
|
+
|
|
50
81
|
## ⚙️ Next.js 설정 (Next.js Configuration)
|
|
51
82
|
|
|
52
83
|
### npm에 배포된 패키지를 사용하는 경우 (권장)
|
|
@@ -60,7 +91,7 @@ npm install @thinkingcat/auth-utils
|
|
|
60
91
|
```json
|
|
61
92
|
{
|
|
62
93
|
"dependencies": {
|
|
63
|
-
"@thinkingcat/auth-utils": "^1.0.
|
|
94
|
+
"@thinkingcat/auth-utils": "^1.0.17"
|
|
64
95
|
}
|
|
65
96
|
}
|
|
66
97
|
```
|
|
@@ -557,7 +588,7 @@ import { createNextAuthBaseConfig } from "@thinkingcat/auth-utils";
|
|
|
557
588
|
|
|
558
589
|
const baseConfig = createNextAuthBaseConfig({
|
|
559
590
|
secret: process.env.NEXTAUTH_SECRET!,
|
|
560
|
-
isProduction: process.env.NODE_ENV ===
|
|
591
|
+
isProduction: process.env.NODE_ENV === "production",
|
|
561
592
|
cookieDomain: process.env.COOKIE_DOMAIN,
|
|
562
593
|
signInPath: "/login",
|
|
563
594
|
errorPath: "/login",
|
|
@@ -595,7 +626,7 @@ JWT 콜백을 위한 통합 헬퍼 함수입니다. 초기 로그인, 토큰 갱
|
|
|
595
626
|
- `options.secret`: NextAuth secret (커스텀 토큰 읽기용)
|
|
596
627
|
- `options.licenseKey`: 라이센스 키 (커스텀 토큰 읽기용)
|
|
597
628
|
- `options.serviceId`: 서비스 ID (커스텀 토큰 읽기용)
|
|
598
|
-
- `options.cookieName`: 커스텀 토큰 쿠키 이름 (기본값: '{serviceId}_access_token')
|
|
629
|
+
- `options.cookieName`: 커스텀 토큰 쿠키 이름 (기본값: '{serviceId}\_access_token')
|
|
599
630
|
- `options.debug`: 디버깅 로그 출력 여부 (기본값: false)
|
|
600
631
|
|
|
601
632
|
**반환값:**
|
|
@@ -1459,10 +1490,32 @@ const response = await handleMiddleware(req, middlewareConfig, {
|
|
|
1459
1490
|
## 📦 패키지 정보
|
|
1460
1491
|
|
|
1461
1492
|
- **패키지명**: `@thinkingcat/auth-utils`
|
|
1462
|
-
- **버전**: `1.0.
|
|
1493
|
+
- **버전**: `1.0.17`
|
|
1463
1494
|
- **라이선스**: MIT
|
|
1464
1495
|
- **저장소**: npm registry
|
|
1465
1496
|
|
|
1497
|
+
## 📝 변경 이력 (Changelog)
|
|
1498
|
+
|
|
1499
|
+
### v1.0.17 (2024-11-15)
|
|
1500
|
+
|
|
1501
|
+
**새로운 기능:**
|
|
1502
|
+
|
|
1503
|
+
- 조건부 로깅 시스템 추가 (`debugLog`, `debugError`)
|
|
1504
|
+
- 환경 변수 `AUTH_UTILS_DEBUG` 지원
|
|
1505
|
+
- 프로덕션 환경에서 로그 출력 최적화
|
|
1506
|
+
|
|
1507
|
+
**개선 사항:**
|
|
1508
|
+
|
|
1509
|
+
- 코드 구조 개선 (15개 기능별 섹션으로 그룹화)
|
|
1510
|
+
- 중복 코드 제거 (`deleteNextAuthSessionCookie`, `clearAllAuthCookies` 헬퍼 추가)
|
|
1511
|
+
- 로그 메시지 간소화 및 가독성 향상
|
|
1512
|
+
- 불필요한 주석 제거
|
|
1513
|
+
|
|
1514
|
+
**성능 최적화:**
|
|
1515
|
+
|
|
1516
|
+
- 프로덕션 환경에서 로그 출력 비활성화로 성능 향상
|
|
1517
|
+
- 조건부 로깅으로 런타임 오버헤드 감소
|
|
1518
|
+
|
|
1466
1519
|
## 🤝 기여 (Contributing)
|
|
1467
1520
|
|
|
1468
1521
|
이슈나 개선 사항이 있으면 GitHub 이슈를 등록해주세요.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { JWT } from "next-auth/jwt";
|
|
1
|
+
import type { JWT } from "next-auth/jwt";
|
|
2
2
|
import type { Session } from "next-auth";
|
|
3
3
|
import { NextResponse, NextRequest } from 'next/server';
|
|
4
4
|
export interface ResponseLike {
|
|
@@ -224,6 +224,7 @@ export declare function verifyAndRefreshToken(req: NextRequest, secret: string,
|
|
|
224
224
|
ssoBaseURL?: string;
|
|
225
225
|
authServiceKey?: string;
|
|
226
226
|
licenseKey: string;
|
|
227
|
+
forceRefresh?: boolean;
|
|
227
228
|
}): Promise<{
|
|
228
229
|
isValid: boolean;
|
|
229
230
|
response?: NextResponse;
|
package/dist/index.js
CHANGED
|
@@ -71,10 +71,27 @@ exports.checkAuthentication = checkAuthentication;
|
|
|
71
71
|
exports.verifyAndRefreshTokenWithNextAuth = verifyAndRefreshTokenWithNextAuth;
|
|
72
72
|
exports.createMiddlewareConfig = createMiddlewareConfig;
|
|
73
73
|
exports.handleMiddleware = handleMiddleware;
|
|
74
|
-
const jwt_1 = require("next-auth/jwt");
|
|
75
74
|
const jose_1 = require("jose");
|
|
76
75
|
const server_1 = require("next/server");
|
|
77
|
-
//
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// UTILITY FUNCTIONS
|
|
78
|
+
// ============================================================================
|
|
79
|
+
/**
|
|
80
|
+
* 조건부 로깅 유틸리티 (환경 변수 AUTH_UTILS_DEBUG=true 시에만 로그 출력)
|
|
81
|
+
*/
|
|
82
|
+
function debugLog(context, ...args) {
|
|
83
|
+
if (process.env.AUTH_UTILS_DEBUG === 'true' || process.env.NODE_ENV === 'development') {
|
|
84
|
+
console.log(`[${context}]`, ...args);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function debugError(context, ...args) {
|
|
88
|
+
if (process.env.AUTH_UTILS_DEBUG === 'true' || process.env.NODE_ENV === 'development') {
|
|
89
|
+
console.error(`[${context}]`, ...args);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Edge Runtime 호환을 위해 Web Crypto API 사용
|
|
94
|
+
*/
|
|
78
95
|
async function createHashSHA256(data) {
|
|
79
96
|
const encoder = new TextEncoder();
|
|
80
97
|
const dataBuffer = encoder.encode(data);
|
|
@@ -82,6 +99,25 @@ async function createHashSHA256(data) {
|
|
|
82
99
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
83
100
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
84
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* NextAuth 세션 토큰 쿠키를 삭제하는 헬퍼 함수
|
|
104
|
+
*/
|
|
105
|
+
function deleteNextAuthSessionCookie(response, isProduction) {
|
|
106
|
+
const cookieName = isProduction
|
|
107
|
+
? '__Secure-next-auth.session-token'
|
|
108
|
+
: 'next-auth.session-token';
|
|
109
|
+
response.cookies.delete(cookieName);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 모든 인증 관련 쿠키를 삭제하는 헬퍼 함수
|
|
113
|
+
*/
|
|
114
|
+
function clearAllAuthCookies(response, cookiePrefix, isProduction) {
|
|
115
|
+
clearAuthCookies(response, cookiePrefix);
|
|
116
|
+
deleteNextAuthSessionCookie(response, isProduction);
|
|
117
|
+
}
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// JWT CORE FUNCTIONS
|
|
120
|
+
// ============================================================================
|
|
85
121
|
/**
|
|
86
122
|
* 토큰 검증 및 디코딩
|
|
87
123
|
* @param accessToken JWT access token
|
|
@@ -126,7 +162,7 @@ function createNextAuthJWT(payload, serviceId) {
|
|
|
126
162
|
const jwt = {
|
|
127
163
|
id: (payload.id || payload.sub),
|
|
128
164
|
email: payload.email,
|
|
129
|
-
name: payload.name,
|
|
165
|
+
name: (payload.name || payload.email || 'User'), // name이 없으면 email 또는 기본값 사용
|
|
130
166
|
role: effectiveRole, // Role enum 타입 (string으로 캐스팅)
|
|
131
167
|
services: payload.services,
|
|
132
168
|
phoneVerified: payload.phoneVerified ?? false,
|
|
@@ -170,11 +206,48 @@ function createNextAuthJWT(payload, serviceId) {
|
|
|
170
206
|
* @returns 인코딩된 세션 토큰
|
|
171
207
|
*/
|
|
172
208
|
async function encodeNextAuthToken(jwt, secret, maxAge = 30 * 24 * 60 * 60) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
209
|
+
try {
|
|
210
|
+
// next-auth/jwt의 encode 함수를 동적 import로 사용 (Edge Runtime 호환)
|
|
211
|
+
const { encode } = await Promise.resolve().then(() => __importStar(require('next-auth/jwt')));
|
|
212
|
+
return await encode({
|
|
213
|
+
token: jwt,
|
|
214
|
+
secret: secret,
|
|
215
|
+
maxAge: maxAge,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
// Edge Runtime에서 encode가 작동하지 않을 수 있으므로
|
|
220
|
+
// jose의 EncryptJWT를 사용하여 JWE 토큰 생성 (NextAuth가 기대하는 형식)
|
|
221
|
+
debugLog('encodeNextAuthToken', 'encode failed, using EncryptJWT fallback');
|
|
222
|
+
// NextAuth는 secret을 SHA-256 해시하여 32바이트 키로 사용
|
|
223
|
+
// jose의 EncryptJWT는 'dir' 알고리즘에서 Uint8Array 키를 직접 사용
|
|
224
|
+
const secretHash = await createHashSHA256(secret);
|
|
225
|
+
// SHA-256 해시는 64자 hex 문자열이므로, 32바이트로 변환
|
|
226
|
+
const keyBytes = new Uint8Array(32);
|
|
227
|
+
for (let i = 0; i < 32; i++) {
|
|
228
|
+
keyBytes[i] = parseInt(secretHash.slice(i * 2, i * 2 + 2), 16);
|
|
229
|
+
}
|
|
230
|
+
const now = Math.floor(Date.now() / 1000);
|
|
231
|
+
// EncryptJWT를 사용하여 JWE 토큰 생성
|
|
232
|
+
// NextAuth는 'dir' 키 관리와 'A256GCM' 암호화를 사용
|
|
233
|
+
// 'dir' 알고리즘은 Uint8Array 키를 직접 사용
|
|
234
|
+
try {
|
|
235
|
+
const token = await new jose_1.EncryptJWT(jwt)
|
|
236
|
+
.setProtectedHeader({
|
|
237
|
+
alg: 'dir', // Direct key agreement
|
|
238
|
+
enc: 'A256GCM' // AES-256-GCM encryption
|
|
239
|
+
})
|
|
240
|
+
.setIssuedAt(now)
|
|
241
|
+
.setExpirationTime(now + maxAge)
|
|
242
|
+
.setJti(crypto.randomUUID())
|
|
243
|
+
.encrypt(keyBytes);
|
|
244
|
+
return token;
|
|
245
|
+
}
|
|
246
|
+
catch (encryptError) {
|
|
247
|
+
debugError('encodeNextAuthToken', 'EncryptJWT also failed:', encryptError);
|
|
248
|
+
throw new Error(`Failed to encode NextAuth token: ${error instanceof Error ? error.message : String(error)}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
178
251
|
}
|
|
179
252
|
function setCustomTokens(response, accessToken, optionsOrRefreshToken, options) {
|
|
180
253
|
// 옵션 파라미터 처리: refreshToken과 options를 분리
|
|
@@ -233,21 +306,15 @@ function setCustomTokens(response, accessToken, optionsOrRefreshToken, options)
|
|
|
233
306
|
*/
|
|
234
307
|
function setNextAuthToken(response, sessionToken, options = {}) {
|
|
235
308
|
const { isProduction = false, cookieDomain, } = options;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
309
|
+
// createNextAuthCookies와 동일한 로직 사용
|
|
310
|
+
const cookies = createNextAuthCookies({ isProduction, cookieDomain });
|
|
311
|
+
const cookieName = cookies.sessionToken.name;
|
|
239
312
|
response.cookies.delete(cookieName);
|
|
240
|
-
|
|
313
|
+
response.cookies.set(cookieName, sessionToken, {
|
|
314
|
+
...cookies.sessionToken.options,
|
|
241
315
|
httpOnly: true,
|
|
242
|
-
secure: isProduction,
|
|
243
|
-
sameSite: 'lax',
|
|
244
316
|
maxAge: 30 * 24 * 60 * 60, // 30일
|
|
245
|
-
|
|
246
|
-
};
|
|
247
|
-
if (cookieDomain) {
|
|
248
|
-
cookieOptions.domain = cookieDomain;
|
|
249
|
-
}
|
|
250
|
-
response.cookies.set(cookieName, sessionToken, cookieOptions);
|
|
317
|
+
});
|
|
251
318
|
}
|
|
252
319
|
/**
|
|
253
320
|
* 리다이렉트용 HTML 생성
|
|
@@ -345,25 +412,29 @@ async function createAuthResponse(accessToken, secret, options) {
|
|
|
345
412
|
throw new Error('Invalid token');
|
|
346
413
|
}
|
|
347
414
|
const { payload } = tokenResult;
|
|
348
|
-
// 2.
|
|
349
|
-
const role = extractRoleFromPayload(payload, serviceId);
|
|
350
|
-
// 3. NextAuth JWT 생성 및 인코딩
|
|
415
|
+
// 2. NextAuth JWT 생성
|
|
351
416
|
const jwt = createNextAuthJWT(payload, serviceId);
|
|
352
|
-
|
|
353
|
-
|
|
417
|
+
debugLog('createAuthResponse', 'JWT created:', {
|
|
418
|
+
hasId: !!jwt.id,
|
|
419
|
+
hasEmail: !!jwt.email,
|
|
420
|
+
hasRole: !!jwt.role,
|
|
421
|
+
});
|
|
422
|
+
// 3. NextAuth 세션 토큰 생성 전략
|
|
423
|
+
// NextAuth의 JWT 콜백이 custom tokens를 읽어서 자동으로 NextAuth 세션을 생성
|
|
424
|
+
debugLog('createAuthResponse', 'Custom tokens will be set, NextAuth JWT callback will handle session creation');
|
|
425
|
+
// 5. HTML 생성
|
|
354
426
|
const displayText = text || serviceId;
|
|
355
427
|
const html = redirectPath
|
|
356
428
|
? createRedirectHTML(redirectPath, displayText)
|
|
357
429
|
: createRedirectHTML('', displayText).replace("window.location.href = ''", "window.location.reload()");
|
|
358
|
-
//
|
|
430
|
+
// 6. Response 생성
|
|
359
431
|
const response = new server_1.NextResponse(html, {
|
|
360
432
|
status: 200,
|
|
361
433
|
headers: {
|
|
362
434
|
'Content-Type': 'text/html',
|
|
363
435
|
},
|
|
364
436
|
});
|
|
365
|
-
//
|
|
366
|
-
// 자체 토큰 설정
|
|
437
|
+
// 4. 쿠키 설정
|
|
367
438
|
if (refreshToken) {
|
|
368
439
|
setCustomTokens(response, accessToken, refreshToken, {
|
|
369
440
|
cookiePrefix,
|
|
@@ -376,15 +447,12 @@ async function createAuthResponse(accessToken, secret, options) {
|
|
|
376
447
|
isProduction,
|
|
377
448
|
});
|
|
378
449
|
}
|
|
379
|
-
|
|
380
|
-
if (sessionToken) {
|
|
381
|
-
setNextAuthToken(response, sessionToken, {
|
|
382
|
-
isProduction,
|
|
383
|
-
cookieDomain,
|
|
384
|
-
});
|
|
385
|
-
}
|
|
450
|
+
debugLog('createAuthResponse', 'Custom tokens set successfully');
|
|
386
451
|
return response;
|
|
387
452
|
}
|
|
453
|
+
// ============================================================================
|
|
454
|
+
// SSO INTEGRATION FUNCTIONS
|
|
455
|
+
// ============================================================================
|
|
388
456
|
/**
|
|
389
457
|
* 서비스 구독 유효성 확인 함수
|
|
390
458
|
* @param services 서비스 정보 배열
|
|
@@ -472,12 +540,16 @@ async function getRefreshTokenFromSSO(userId, accessToken, options) {
|
|
|
472
540
|
}
|
|
473
541
|
return null;
|
|
474
542
|
}
|
|
543
|
+
// ============================================================================
|
|
544
|
+
// TOKEN REFRESH & VERIFICATION FUNCTIONS
|
|
545
|
+
// ============================================================================
|
|
475
546
|
async function verifyAndRefreshToken(req, secret, options) {
|
|
476
|
-
const { cookiePrefix, serviceId, isProduction, cookieDomain, text, ssoBaseURL, authServiceKey, } = options;
|
|
547
|
+
const { cookiePrefix, serviceId, isProduction, cookieDomain, text, ssoBaseURL, authServiceKey, forceRefresh = false, } = options;
|
|
477
548
|
// 1. access_token 쿠키 확인
|
|
549
|
+
// forceRefresh가 true이면 access token이 있어도 refresh를 시도
|
|
478
550
|
const accessTokenName = `${cookiePrefix}_access_token`;
|
|
479
551
|
const accessToken = req.cookies.get(accessTokenName)?.value;
|
|
480
|
-
if (accessToken) {
|
|
552
|
+
if (accessToken && !forceRefresh) {
|
|
481
553
|
try {
|
|
482
554
|
const secretBytes = new TextEncoder().encode(secret);
|
|
483
555
|
const { payload } = await (0, jose_1.jwtVerify)(accessToken, secretBytes);
|
|
@@ -492,15 +564,25 @@ async function verifyAndRefreshToken(req, secret, options) {
|
|
|
492
564
|
// 리프레시 토큰으로 갱신 시도
|
|
493
565
|
const refreshTokenName = `${cookiePrefix}_refresh_token`;
|
|
494
566
|
const refreshToken = req.cookies.get(refreshTokenName)?.value;
|
|
567
|
+
debugLog('verifyAndRefreshToken', 'Checking refresh:', {
|
|
568
|
+
hasRefreshToken: !!refreshToken,
|
|
569
|
+
forceRefresh,
|
|
570
|
+
});
|
|
495
571
|
if (refreshToken) {
|
|
496
572
|
try {
|
|
497
573
|
if (!ssoBaseURL || !authServiceKey) {
|
|
574
|
+
debugLog('verifyAndRefreshToken', 'SSO config missing');
|
|
498
575
|
return { isValid: false, error: 'SSO_CONFIG_MISSING' };
|
|
499
576
|
}
|
|
577
|
+
debugLog('verifyAndRefreshToken', 'Attempting token refresh...');
|
|
500
578
|
const refreshResult = await refreshSSOToken(refreshToken, {
|
|
501
579
|
ssoBaseURL,
|
|
502
580
|
authServiceKey,
|
|
503
581
|
});
|
|
582
|
+
debugLog('verifyAndRefreshToken', 'Refresh result:', {
|
|
583
|
+
success: refreshResult.success,
|
|
584
|
+
hasAccessToken: !!refreshResult.accessToken,
|
|
585
|
+
});
|
|
504
586
|
if (refreshResult.success && refreshResult.accessToken) {
|
|
505
587
|
const newRefreshToken = refreshResult.refreshToken || refreshToken;
|
|
506
588
|
try {
|
|
@@ -515,6 +597,7 @@ async function verifyAndRefreshToken(req, secret, options) {
|
|
|
515
597
|
catch {
|
|
516
598
|
// 토큰 검증 실패
|
|
517
599
|
}
|
|
600
|
+
debugLog('verifyAndRefreshToken', 'Creating auth response...');
|
|
518
601
|
const response = await createAuthResponse(refreshResult.accessToken, secret, {
|
|
519
602
|
refreshToken: newRefreshToken,
|
|
520
603
|
redirectPath: '',
|
|
@@ -528,21 +611,29 @@ async function verifyAndRefreshToken(req, secret, options) {
|
|
|
528
611
|
return { isValid: true, response, payload };
|
|
529
612
|
}
|
|
530
613
|
catch (error) {
|
|
531
|
-
|
|
614
|
+
debugError('verifyAndRefreshToken', 'Failed to create auth response:', error);
|
|
532
615
|
return { isValid: false, error: 'SESSION_CREATION_FAILED' };
|
|
533
616
|
}
|
|
534
617
|
}
|
|
535
618
|
else {
|
|
536
|
-
|
|
619
|
+
debugLog('verifyAndRefreshToken', 'Refresh failed, clearing all cookies');
|
|
620
|
+
const response = server_1.NextResponse.next();
|
|
621
|
+
clearAllAuthCookies(response, options.cookiePrefix, options.isProduction);
|
|
622
|
+
return { isValid: false, response, error: 'REFRESH_FAILED' };
|
|
537
623
|
}
|
|
538
624
|
}
|
|
539
625
|
catch (error) {
|
|
540
|
-
|
|
541
|
-
|
|
626
|
+
debugError('verifyAndRefreshToken', 'Token refresh error:', error);
|
|
627
|
+
const response = server_1.NextResponse.next();
|
|
628
|
+
clearAllAuthCookies(response, options.cookiePrefix, options.isProduction);
|
|
629
|
+
return { isValid: false, response, error: 'REFRESH_ERROR' };
|
|
542
630
|
}
|
|
543
631
|
}
|
|
544
632
|
return { isValid: false, error: 'NO_TOKEN' };
|
|
545
633
|
}
|
|
634
|
+
// ============================================================================
|
|
635
|
+
// HELPER FUNCTIONS
|
|
636
|
+
// ============================================================================
|
|
546
637
|
/**
|
|
547
638
|
* 에러 페이지로 리다이렉트하는 헬퍼 함수
|
|
548
639
|
* @param req NextRequest 객체
|
|
@@ -602,6 +693,9 @@ function requiresSubscription(pathname, role, subscriptionRequiredPaths, systemA
|
|
|
602
693
|
// 구독이 필요한 경로인지 확인
|
|
603
694
|
return subscriptionRequiredPaths.some(path => pathname.startsWith(path));
|
|
604
695
|
}
|
|
696
|
+
// ============================================================================
|
|
697
|
+
// NEXTAUTH CONFIGURATION FUNCTIONS
|
|
698
|
+
// ============================================================================
|
|
605
699
|
// 유효한 라이센스 키 해시 목록
|
|
606
700
|
const VALID_LICENSE_KEY_HASHES = new Set([
|
|
607
701
|
'73bce4f3b64804c255cdab450d759a8b53038f9edb59ae42d9988b08dfd007e2',
|
|
@@ -616,12 +710,16 @@ const VALID_LICENSE_KEY_HASHES = new Set([
|
|
|
616
710
|
function createNextAuthCookies(options) {
|
|
617
711
|
const { isProduction = false, cookieDomain } = options;
|
|
618
712
|
const isSecure = isProduction;
|
|
713
|
+
// cookieDomain이 설정되어 있으면 같은 도메인/서브도메인 간 쿠키 공유를 위해 'lax' 사용
|
|
714
|
+
// cookieDomain이 없고 프로덕션 환경이면 크로스 도메인을 위해 'none' 사용
|
|
715
|
+
// 개발 환경이면 항상 'lax' 사용
|
|
716
|
+
const sameSiteValue = cookieDomain ? 'lax' : (isSecure ? 'none' : 'lax');
|
|
619
717
|
return {
|
|
620
718
|
sessionToken: {
|
|
621
719
|
name: isSecure ? `__Secure-next-auth.session-token` : `next-auth.session-token`,
|
|
622
720
|
options: {
|
|
623
721
|
httpOnly: true,
|
|
624
|
-
sameSite:
|
|
722
|
+
sameSite: sameSiteValue,
|
|
625
723
|
path: '/',
|
|
626
724
|
secure: isSecure,
|
|
627
725
|
...(cookieDomain && { domain: cookieDomain }),
|
|
@@ -630,7 +728,7 @@ function createNextAuthCookies(options) {
|
|
|
630
728
|
callbackUrl: {
|
|
631
729
|
name: isSecure ? `__Secure-next-auth.callback-url` : `next-auth.callback-url`,
|
|
632
730
|
options: {
|
|
633
|
-
sameSite:
|
|
731
|
+
sameSite: sameSiteValue,
|
|
634
732
|
path: '/',
|
|
635
733
|
secure: isSecure,
|
|
636
734
|
...(cookieDomain && { domain: cookieDomain }),
|
|
@@ -640,7 +738,7 @@ function createNextAuthCookies(options) {
|
|
|
640
738
|
name: isSecure ? `__Secure-next-auth.csrf-token` : `next-auth.csrf-token`,
|
|
641
739
|
options: {
|
|
642
740
|
httpOnly: true,
|
|
643
|
-
sameSite:
|
|
741
|
+
sameSite: sameSiteValue,
|
|
644
742
|
path: '/',
|
|
645
743
|
secure: isSecure,
|
|
646
744
|
...(cookieDomain && { domain: cookieDomain }),
|
|
@@ -767,29 +865,37 @@ async function handleJWTCallback(token, user, account, options) {
|
|
|
767
865
|
const { secret, licenseKey, serviceId, cookieName, debug = false, } = options || {};
|
|
768
866
|
// 디버깅 로그
|
|
769
867
|
if (debug) {
|
|
770
|
-
|
|
868
|
+
debugLog('handleJWTCallback', 'Token received:', {
|
|
771
869
|
hasId: !!token.id,
|
|
772
870
|
hasEmail: !!token.email,
|
|
773
871
|
hasRole: !!token.role,
|
|
774
|
-
tokenKeys: Object.keys(token),
|
|
775
872
|
});
|
|
776
873
|
}
|
|
777
874
|
// 1. 초기 로그인 시 (providers를 통한 로그인)
|
|
778
875
|
if (account && user) {
|
|
876
|
+
debugLog('handleJWTCallback', 'Initial login, creating token from user data');
|
|
779
877
|
return createInitialJWTToken(token, user, account);
|
|
780
878
|
}
|
|
781
879
|
// 2. 이미 토큰에 정보가 있으면 그대로 사용
|
|
782
880
|
if (token.id) {
|
|
881
|
+
debugLog('handleJWTCallback', 'Token already has id, using existing token');
|
|
783
882
|
return token;
|
|
784
883
|
}
|
|
785
884
|
// 3. 토큰에 id가 없는 경우 - 커스텀 토큰 쿠키에서 정보 읽기
|
|
885
|
+
debugLog('handleJWTCallback', 'Token has no id, checking custom token cookie');
|
|
786
886
|
if (secret && licenseKey && serviceId) {
|
|
787
887
|
const cookieNameToUse = cookieName || `${serviceId}_access_token`;
|
|
788
888
|
const jwt = await getJWTFromCustomTokenCookie(cookieNameToUse, secret, serviceId, licenseKey);
|
|
789
889
|
if (jwt) {
|
|
890
|
+
debugLog('handleJWTCallback', 'Successfully created JWT from custom token cookie');
|
|
790
891
|
return jwt;
|
|
791
892
|
}
|
|
893
|
+
debugLog('handleJWTCallback', 'Failed to create JWT from custom token cookie');
|
|
894
|
+
}
|
|
895
|
+
else {
|
|
896
|
+
debugLog('handleJWTCallback', 'Missing required parameters for custom token reading');
|
|
792
897
|
}
|
|
898
|
+
debugLog('handleJWTCallback', 'Returning original token');
|
|
793
899
|
return token;
|
|
794
900
|
}
|
|
795
901
|
/**
|
|
@@ -802,27 +908,34 @@ async function handleJWTCallback(token, user, account, options) {
|
|
|
802
908
|
* @returns NextAuth JWT 객체 또는 null
|
|
803
909
|
*/
|
|
804
910
|
async function getJWTFromCustomTokenCookie(cookieName, secret, serviceId, licenseKey) {
|
|
911
|
+
debugLog('getJWTFromCustomTokenCookie', `Reading cookie: ${cookieName}`);
|
|
805
912
|
try {
|
|
806
913
|
const { cookies } = await Promise.resolve().then(() => __importStar(require('next/headers')));
|
|
807
914
|
const cookieStore = await cookies();
|
|
808
915
|
const accessToken = cookieStore.get(cookieName)?.value;
|
|
809
916
|
if (!accessToken) {
|
|
917
|
+
debugLog('getJWTFromCustomTokenCookie', 'No access token found in cookies');
|
|
810
918
|
return null;
|
|
811
919
|
}
|
|
812
920
|
await checkLicenseKey(licenseKey);
|
|
813
921
|
const tokenResult = await verifyToken(accessToken, secret);
|
|
814
922
|
if (!tokenResult) {
|
|
923
|
+
debugLog('getJWTFromCustomTokenCookie', 'Token verification failed');
|
|
815
924
|
return null;
|
|
816
925
|
}
|
|
817
926
|
const { payload } = tokenResult;
|
|
818
927
|
const jwt = createNextAuthJWT(payload, serviceId);
|
|
928
|
+
debugLog('getJWTFromCustomTokenCookie', 'JWT created successfully from custom token');
|
|
819
929
|
return jwt;
|
|
820
930
|
}
|
|
821
931
|
catch (error) {
|
|
822
|
-
|
|
932
|
+
debugError('getJWTFromCustomTokenCookie', `Failed to read ${cookieName}:`, error);
|
|
823
933
|
return null;
|
|
824
934
|
}
|
|
825
935
|
}
|
|
936
|
+
// ============================================================================
|
|
937
|
+
// LICENSE & AUTHORIZATION FUNCTIONS
|
|
938
|
+
// ============================================================================
|
|
826
939
|
async function checkLicenseKey(licenseKey) {
|
|
827
940
|
if (!licenseKey || licenseKey.length < 10) {
|
|
828
941
|
throw new Error('License key is required');
|
|
@@ -848,6 +961,9 @@ function checkRoleAccess(pathname, role, roleConfig) {
|
|
|
848
961
|
// 매칭되는 경로가 없으면 허용
|
|
849
962
|
return { allowed: true };
|
|
850
963
|
}
|
|
964
|
+
// ============================================================================
|
|
965
|
+
// REDIRECT FUNCTIONS
|
|
966
|
+
// ============================================================================
|
|
851
967
|
/**
|
|
852
968
|
* SSO 로그인 페이지로 리다이렉트하는 헬퍼 함수
|
|
853
969
|
* @param req NextRequest 객체
|
|
@@ -870,6 +986,9 @@ function redirectToRoleDashboard(req, role, rolePaths, defaultPath = '/admin') {
|
|
|
870
986
|
const redirectPath = rolePaths[role] || defaultPath;
|
|
871
987
|
return server_1.NextResponse.redirect(new URL(redirectPath, req.url));
|
|
872
988
|
}
|
|
989
|
+
// ============================================================================
|
|
990
|
+
// TOKEN VALIDATION UTILITIES
|
|
991
|
+
// ============================================================================
|
|
873
992
|
/**
|
|
874
993
|
* 토큰이 만료되었는지 확인하는 함수
|
|
875
994
|
* @param token NextAuth JWT 객체
|
|
@@ -923,6 +1042,9 @@ function hasAnyRole(token, roles, serviceId) {
|
|
|
923
1042
|
const effectiveRole = getEffectiveRole(token, serviceId);
|
|
924
1043
|
return roles.includes(effectiveRole || '');
|
|
925
1044
|
}
|
|
1045
|
+
// ============================================================================
|
|
1046
|
+
// PATH UTILITIES
|
|
1047
|
+
// ============================================================================
|
|
926
1048
|
/**
|
|
927
1049
|
* 공개 경로인지 확인하는 함수
|
|
928
1050
|
* @param pathname 경로명
|
|
@@ -951,6 +1073,9 @@ function isProtectedApiPath(pathname, exemptPaths = []) {
|
|
|
951
1073
|
return false;
|
|
952
1074
|
return !exemptPaths.some(path => pathname.startsWith(path));
|
|
953
1075
|
}
|
|
1076
|
+
// ============================================================================
|
|
1077
|
+
// AUTHENTICATION CHECK FUNCTIONS
|
|
1078
|
+
// ============================================================================
|
|
954
1079
|
/**
|
|
955
1080
|
* NextAuth 토큰과 자체 토큰을 모두 확인하는 통합 인증 체크 함수
|
|
956
1081
|
* @param req NextRequest 객체
|
|
@@ -999,12 +1124,59 @@ async function checkAuthentication(req, secret, options) {
|
|
|
999
1124
|
* @returns 인증 결과
|
|
1000
1125
|
*/
|
|
1001
1126
|
async function verifyAndRefreshTokenWithNextAuth(req, nextAuthToken, secret, options) {
|
|
1002
|
-
|
|
1127
|
+
const { cookiePrefix, isProduction } = options;
|
|
1128
|
+
// NextAuth 세션 토큰 쿠키 확인
|
|
1129
|
+
const nextAuthSessionTokenCookieName = isProduction
|
|
1130
|
+
? '__Secure-next-auth.session-token'
|
|
1131
|
+
: 'next-auth.session-token';
|
|
1132
|
+
const hasNextAuthSessionTokenCookie = !!req.cookies.get(nextAuthSessionTokenCookieName)?.value;
|
|
1133
|
+
// NextAuth 토큰 확인
|
|
1134
|
+
const hasValidNextAuthToken = nextAuthToken && isValidToken(nextAuthToken);
|
|
1135
|
+
// Access token 확인
|
|
1136
|
+
const accessTokenName = `${cookiePrefix}_access_token`;
|
|
1137
|
+
const accessToken = req.cookies.get(accessTokenName)?.value;
|
|
1138
|
+
let hasValidAccessToken = false;
|
|
1139
|
+
if (accessToken) {
|
|
1140
|
+
try {
|
|
1141
|
+
const secretBytes = new TextEncoder().encode(secret);
|
|
1142
|
+
const { payload } = await (0, jose_1.jwtVerify)(accessToken, secretBytes);
|
|
1143
|
+
if (payload && typeof payload === 'object' && payload.email) {
|
|
1144
|
+
hasValidAccessToken = true;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
catch {
|
|
1148
|
+
// 토큰 검증 실패
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
// Refresh token 확인
|
|
1152
|
+
const refreshTokenName = `${cookiePrefix}_refresh_token`;
|
|
1153
|
+
const refreshToken = req.cookies.get(refreshTokenName)?.value;
|
|
1154
|
+
debugLog('verifyAndRefreshTokenWithNextAuth', 'Token status:', {
|
|
1155
|
+
hasNextAuthCookie: hasNextAuthSessionTokenCookie,
|
|
1156
|
+
hasValidNextAuth: hasValidNextAuthToken,
|
|
1157
|
+
hasValidAccess: hasValidAccessToken,
|
|
1158
|
+
hasRefresh: !!refreshToken,
|
|
1159
|
+
});
|
|
1160
|
+
// NextAuth 토큰 또는 access token 중 하나라도 유효하면 통과
|
|
1161
|
+
if (hasValidNextAuthToken || hasValidAccessToken) {
|
|
1162
|
+
debugLog('verifyAndRefreshTokenWithNextAuth', 'At least one token is valid');
|
|
1003
1163
|
return { isValid: true };
|
|
1004
1164
|
}
|
|
1005
|
-
|
|
1006
|
-
|
|
1165
|
+
// 둘 다 없으면 refresh token으로 갱신 시도
|
|
1166
|
+
if (refreshToken) {
|
|
1167
|
+
debugLog('verifyAndRefreshTokenWithNextAuth', 'No valid tokens, attempting refresh');
|
|
1168
|
+
const authCheck = await verifyAndRefreshToken(req, secret, {
|
|
1169
|
+
...options,
|
|
1170
|
+
forceRefresh: true,
|
|
1171
|
+
});
|
|
1172
|
+
return authCheck;
|
|
1173
|
+
}
|
|
1174
|
+
debugLog('verifyAndRefreshTokenWithNextAuth', 'No tokens available');
|
|
1175
|
+
return { isValid: false, error: 'NO_TOKEN' };
|
|
1007
1176
|
}
|
|
1177
|
+
// ============================================================================
|
|
1178
|
+
// MIDDLEWARE CONFIGURATION & HANDLER
|
|
1179
|
+
// ============================================================================
|
|
1008
1180
|
/**
|
|
1009
1181
|
* 기본 미들웨어 설정을 생성하는 함수
|
|
1010
1182
|
* @param config 커스텀 설정 (필수: serviceId 포함)
|
|
@@ -1081,6 +1253,7 @@ async function handleMiddleware(req, config, options) {
|
|
|
1081
1253
|
try {
|
|
1082
1254
|
const pathname = req.nextUrl.pathname;
|
|
1083
1255
|
const { secret, isProduction, cookieDomain, getNextAuthToken, licenseKey } = options;
|
|
1256
|
+
debugLog('handleMiddleware', `Processing: ${pathname}`);
|
|
1084
1257
|
await checkLicenseKey(licenseKey);
|
|
1085
1258
|
if (!config.serviceId) {
|
|
1086
1259
|
throw new Error('serviceId is required in middleware config');
|
|
@@ -1092,7 +1265,6 @@ async function handleMiddleware(req, config, options) {
|
|
|
1092
1265
|
token = await getNextAuthToken(req);
|
|
1093
1266
|
}
|
|
1094
1267
|
else {
|
|
1095
|
-
// 기본적으로 secret을 사용하여 토큰 가져오기
|
|
1096
1268
|
try {
|
|
1097
1269
|
const { getToken } = await Promise.resolve().then(() => __importStar(require('next-auth/jwt')));
|
|
1098
1270
|
token = await getToken({ req, secret });
|
|
@@ -1101,7 +1273,12 @@ async function handleMiddleware(req, config, options) {
|
|
|
1101
1273
|
// NextAuth가 없으면 null 유지
|
|
1102
1274
|
}
|
|
1103
1275
|
}
|
|
1276
|
+
debugLog('handleMiddleware', 'Token status:', {
|
|
1277
|
+
hasToken: !!token,
|
|
1278
|
+
hasId: !!token?.id,
|
|
1279
|
+
});
|
|
1104
1280
|
const effectiveRole = getEffectiveRole(token, serviceId);
|
|
1281
|
+
debugLog('handleMiddleware', `Effective role: ${effectiveRole || 'none'}`);
|
|
1105
1282
|
// 1. API 요청 처리
|
|
1106
1283
|
if (pathname.startsWith('/api/')) {
|
|
1107
1284
|
if (config.authApiPaths.includes(pathname)) {
|
|
@@ -1133,6 +1310,7 @@ async function handleMiddleware(req, config, options) {
|
|
|
1133
1310
|
if (pathname === '/') {
|
|
1134
1311
|
const tokenParam = req.nextUrl.searchParams.get('token');
|
|
1135
1312
|
if (tokenParam) {
|
|
1313
|
+
debugLog('handleMiddleware', 'Processing SSO token from query parameter');
|
|
1136
1314
|
try {
|
|
1137
1315
|
// 1. 토큰 검증
|
|
1138
1316
|
const tokenResult = await verifyToken(tokenParam, secret);
|
|
@@ -1143,6 +1321,7 @@ async function handleMiddleware(req, config, options) {
|
|
|
1143
1321
|
// 2. 역할 추출
|
|
1144
1322
|
const defaultRole = Object.keys(config.rolePaths)[0] || 'ADMIN';
|
|
1145
1323
|
const tokenRole = extractRoleFromPayload(payload, serviceId, defaultRole);
|
|
1324
|
+
debugLog('handleMiddleware', `Extracted role: ${tokenRole}`);
|
|
1146
1325
|
// 3. Refresh token 가져오기 (서버 간 통신)
|
|
1147
1326
|
const userId = payload.sub || payload.userId || '';
|
|
1148
1327
|
const ssoBaseURL = options.ssoBaseURL;
|
|
@@ -1152,6 +1331,7 @@ async function handleMiddleware(req, config, options) {
|
|
|
1152
1331
|
: '';
|
|
1153
1332
|
// 4. 자체 토큰 생성 및 쿠키 설정
|
|
1154
1333
|
const redirectPath = config.rolePaths[tokenRole] || config.rolePaths[defaultRole] || '/admin';
|
|
1334
|
+
debugLog('handleMiddleware', `Creating auth response, redirect to: ${redirectPath}`);
|
|
1155
1335
|
const response = await createAuthResponse(tokenParam, secret, {
|
|
1156
1336
|
refreshToken: refreshToken || undefined,
|
|
1157
1337
|
redirectPath,
|
|
@@ -1164,8 +1344,8 @@ async function handleMiddleware(req, config, options) {
|
|
|
1164
1344
|
});
|
|
1165
1345
|
return response;
|
|
1166
1346
|
}
|
|
1167
|
-
catch {
|
|
1168
|
-
|
|
1347
|
+
catch (error) {
|
|
1348
|
+
debugError('handleMiddleware', 'Error processing token:', error);
|
|
1169
1349
|
const ssoBaseURL = options.ssoBaseURL;
|
|
1170
1350
|
return redirectToSSOLogin(req, serviceId, ssoBaseURL);
|
|
1171
1351
|
}
|
|
@@ -1265,10 +1445,10 @@ async function handleMiddleware(req, config, options) {
|
|
|
1265
1445
|
return server_1.NextResponse.redirect(subscriptionCheck.redirectUrl);
|
|
1266
1446
|
}
|
|
1267
1447
|
}
|
|
1268
|
-
return null;
|
|
1448
|
+
return null;
|
|
1269
1449
|
}
|
|
1270
1450
|
catch (error) {
|
|
1271
|
-
|
|
1451
|
+
debugError('handleMiddleware', 'Middleware error:', error);
|
|
1272
1452
|
return redirectToError(req, 'INTERNAL_ERROR', '서버 오류가 발생했습니다.', config.errorPath);
|
|
1273
1453
|
}
|
|
1274
1454
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thinkingcat/auth-utils",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Authentication utilities for ThinkingCat SSO services",
|
|
3
|
+
"version": "1.0.18",
|
|
4
|
+
"description": "Authentication utilities for ThinkingCat SSO services with conditional logging",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"exports": {
|
|
@@ -40,6 +40,3 @@
|
|
|
40
40
|
".env.example"
|
|
41
41
|
]
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|