import 'server-only'; import { backendFetch } from '@/lib/http/backend-fetch'; import { decodeJwtPayload } from '@/lib/auth/decode-jwt-payload'; import type { AdminUser } from '@/lib/domain/admin-user'; /** * 관리자 인증 Repository — 백엔드(edupay-backend) 실연동. * * 엔드포인트: `POST /api/v1/common/auth/admin/login` (baseURL: lib/env.ts의 getApiBaseUrl()). * 요청 필드명은 `loginPw`다(`password`가 아님 — 백엔드 계약 그대로). * * 백엔드에는 관리자 프로필 조회 API가 없다(2026-08 기준) — 그래서 로그인 응답으로 받은 * accessToken(JWT)의 payload를 디코딩해 AdminUser를 구성한다. 서명 검증은 하지 않는다 * (백엔드 시크릿이 없어 불가능 — lib/auth/decode-jwt-payload.ts 참고, 위조 방지는 이후 * 우리 세션 HMAC 서명이 담당한다). 관리자 "이름" 클레임이 토큰에 없어 loginId로 대체한다 * (사용자 확정 사항). */ export type AdminCredentials = { loginId: string; password: string; }; export type AdminLoginResult = { admin: AdminUser; accessToken: string; refreshToken: string; /** 백엔드 accessToken의 `exp` 클레임(unix seconds) — 세션 만료를 이 값에 정렬시키는 데 쓴다. */ accessTokenExpiresAt: number; }; const ADMIN_LOGOUT_PATH = '/api/v1/common/auth/logout'; /** 백엔드 accessToken(JWT)의 payload 클레임. */ type AdminAccessTokenClaims = { sub: string; loginId: string; admRoleCd: string; adminId: string; userType: string; userId: string; iat: number; exp: number; }; type AdminLoginResponse = { accessToken: string; refreshToken: string; }; /** * 관리자 로그인을 시도한다. 자격 불일치·존재하지 않는 계정 등 인증 실패는 예외가 아니라 * 정상 흐름이므로 null을 반환한다(네트워크·파싱 등 진짜 예외 상황은 lib/http/backend-fetch.ts가 * 흡수해 동일하게 `ok:false`로 내려주므로 이 함수 입장에서는 실패 사유를 구분하지 않는다 — * 호출부가 항상 일반화된 오류 메시지로 응답하기 때문에 구분할 필요가 없다). * * 캐시 전략: `no-store` — 인증 요청은 재사용 캐시 대상이 아니다(매 시도가 백엔드에 도달해야 함). */ export async function requestAdminLogin( credentials: AdminCredentials ): Promise { const result = await backendFetch( '/api/v1/common/auth/admin/login', { method: 'POST', body: { loginId: credentials.loginId, loginPw: credentials.password }, cache: 'no-store', } ); if (!result.ok) { return null; } const claims = decodeJwtPayload(result.data.accessToken); if ( !claims || typeof claims.adminId !== 'string' || !claims.adminId || typeof claims.loginId !== 'string' || !claims.loginId || typeof claims.admRoleCd !== 'string' || !claims.admRoleCd || typeof claims.exp !== 'number' ) { return null; } const admin: AdminUser = { id: claims.adminId, // 백엔드 accessToken에 관리자 이름 클레임이 없어 loginId로 대체한다(사용자 확정 사항). name: claims.loginId, loginId: claims.loginId, roleCode: claims.admRoleCd, }; return { admin, accessToken: result.data.accessToken, refreshToken: result.data.refreshToken, accessTokenExpiresAt: claims.exp, }; } /** * 관리자 로그아웃을 백엔드에 알린다 — `GET /api/v1/common/auth/logout`(인증 필요, AUTH_WHITELIST에 * 없음을 확인함: edupay-backend SecurityConfig.java). * * JWT는 stateless라 이 호출이 accessToken 자체를 무효화하지는 않는다 — 발급된 토큰은 만료까지 * 계속 유효하다. 백엔드가 실질적으로 하는 일은 FCM 토큰 삭제뿐이다(edupay-backend * CmmAuthApiController#actionLogoutJSON: `deleteFcmToken` + `SecurityContextLogoutHandler.logout`, * 후자는 STATELESS 세션 정책상 사실상 무의미). 그래도 정식 절차이므로 호출은 한다. * * 성공 응답의 data는 항상 null이다(`ApiResponseVO.success(null)`) — `canHaveNullData: true`로 * 이 정상 응답을 실패로 오판하지 않게 한다(lib/http/backend-fetch.ts 참고). * * 실패(백엔드 다운·accessToken 만료 등)를 예외로 다루지 않고 boolean으로 반환한다 — 로그아웃은 * 백엔드 호출이 실패해도 반드시 로컬에서는 진행돼야 하므로, 호출부(Server Action)가 try/catch * 없이 결과만 보고 흐름을 결정할 수 있게 한다. * * 캐시 전략: `no-store` — 상태를 변경하는 요청이라 캐시 대상이 아니다. */ export async function requestAdminLogout(accessToken: string): Promise { const result = await backendFetch(ADMIN_LOGOUT_PATH, { method: 'GET', accessToken, cache: 'no-store', canHaveNullData: true, }); return result.ok; }