Auth Flow (login · refresh · impersonation)
SPEC #003 · #005 · #006 정합.
Overview
세 가지 핵심 인증 흐름이 있다:
- Login — 이메일 + 비밀번호 → access (15min) + refresh (7d) → sealed cookie
- Refresh — access 만료 임박 또는 401 → refresh 사용 → 새 토큰 rotation
- Impersonation — 운영사 OPERATOR → 본사 모드로 대신 진입 (새 탭 + 빨간 배너)
1. Login Flow
2. Refresh Flow
access 만료 임박 (≤30s) 또는 401 발생 시 BFF 가 투명하게 갱신.
핵심:
- 5xx → session 유지 + 502
BACKEND_ERROR([[feedback-1]]). 일시 장애로 강제 로그아웃 X. - 401 → session.destroy() + 401
AUTH_UNAUTHENTICATED→ 로그인 redirect. - 1회 재시도 후에도 401 이면 포기.
2-1. 세션 무효 401 의 두 redirect 경로 (server vs client)
세션이 무효(만료 + refresh 실패)일 때 BFF 는 session.destroy() 후 401 { code: "AUTH_UNAUTHENTICATED" } 를 돌려준다. 이를 /login 으로 보내는 경로가 호출자에 따라 두 갈래다 (SPEC #030):
- 서버 경로 (server component) — protected
layout.tsx·refresh-aware page 가loadMeRefreshAware/refreshIfNeeded결과로 세션 무효를 감지하면redirect("/login")(Next server redirect). 보호 URL 직접 진입·새로고침이 여기에 해당. - 클라이언트 경로 (browser react-query 등) — 대시보드 통계(
useGetAdminStats)·다이얼로그 mutation 처럼 브라우저에서 BFF/api/backend/*를 치는 호출. 응답이401 AUTH_UNAUTHENTICATED이면apiFetch(packages/api-client/src/mutator.ts) 가 전역으로window.location.assign("/login?next=" + encodeURIComponent(location.pathname + location.search))풀 페이지 네비게이션한다. 세션이 서버에서 파기됐으므로 SPA 라우팅이 아닌 풀 리로드로 깨끗이 재인증하고next로 원위치 복귀한다.
mutator 전역 redirect 가드 (redirectToLoginIfSessionInvalid):
- 브라우저 한정 (
typeof window !== "undefined") — SSR 호출은 위 server redirect 가 처리하므로 건드리지 않는다. - code 한정 — 정확히
AUTH_UNAUTHENTICATED인 401 만 대상. 그 외 401(예:NO_SESSION·일시 오류)·403·404·5xx 는 기존대로ApiErrorthrow → 각 화면이 처리. - 루프 가드 — 이미
/login경로면 재네비게이션하지 않는다. (로그인/refresh 흐름은/api/auth/*별도 BFF 라 애초에apiFetch를 거치지 않지만 방어적으로.) - 그 전까지는 화면이 5xx 와 동일하게 “서버 에러” 배너를 띄웠다 — 이 변경으로 만료/무효 세션에서 클라 호출 시 로그인 화면으로 자연 이동한다.
3. Impersonation Flow (cross-origin handoff — apps/admin → apps/space)
운영사 OPERATOR 가 본사 (FRANCHISE · SUSPENDED 아님 — ACTIVE·ONBOARDING·UNPAID) 로 대신 진입.
도착지는 진짜 본사 기능이 단일 소스로 있는 apps/space /admin/*(announcements·commercials·
libraries·playlists·stores·support·settings·audit)이다. apps/admin 은 발급만 하고 새 탭을
apps/space origin 으로 연다. backend 는 토큰 기반 + BFF 프록시라 origin 무관 — 무변경이다.
핵심:
- cross-origin 새 탭 —
apps/admin의 원 OPERATORlm_session은 admin 탭에서 그대로 유지. 새 탭은NEXT_PUBLIC_SPACE_ORIGIN(클라이언트 노출 env, apps/admin) 으로 조립한 절대 URL 로 연다. 미설정이면 [전환] 이 에러를 surface 하고 탭을 열지 않는다(조용한 무산 방지). - fragment 토큰 —
#token=...은 cross-originwindow.open에서도 서버에 전송되지 않음(브라우저 only). 도착 페이지가 읽은 즉시clearFragment로 제거해 누출·새로고침 재시도를 막는다. - 봉인·교환은 apps/space — 토큰 교환·sealed cookie 봉인 책임은 도착지
apps/space의 BFF route(/api/auth/impersonate-exchange)가 진다. apps/admin 은 더 이상 임퍼소네이션 세션을 만들지 않는다. - 빨간 배너 항시 —
apps/space/admin/*(본사 모드 페이지) 전역에 고정.--imp-alert토큰 (@linkmusic/ui). 60분 카운트다운 0:00 도달 시 만료 화면으로 전환. - 세션 분리 — 임퍼소네이션 세션은
apps/space의 실 로그인lm_space_session과 별도 cookie 이름(lm_space_impersonation_session)으로 분리 봉인된다. 같은 브라우저 다른 탭에서 공존. - refresh 60min — 일반 7일과 다름. v1 은 고정 60분(자동 refresh 없음).
- 데이터 경로도 임퍼소네이션 우선 —
/admin/*본사 데이터는 catch-all proxy (/api/backend/[...path]) 와 server 로더가 공통 헬퍼getActiveAdminSession()(layout 과 동일 우선순위 — 임퍼소네이션 세션 있으면 그 토큰, 없으면 실 로그인)으로 forward 한다. 배너 신원과 forward 토큰 신원이 일치한다. 임퍼소네이션 토큰은 access·refresh 둘 다 60분이라 401 시 refresh 없이 세션 destroy + 401(SESSION_EXPIRED) 로 fail-closed(refreshIfNeeded/forceRefresh는impersonatedBy마커로 임퍼소네이션 세션을 건너뛴다). 점장(/store/*)은 임퍼소네이션과 무관.
4. apps/space /admin 진입 두 경로
본사 모드 셸(apps/space /admin/*)은 두 세션 모드를 모두 해석한다. app/admin/layout.tsx 가
유일한 가드이며(middleware 는 /admin/* 를 bypass), 우선순위는 임퍼소네이션 우선.
- 임퍼소네이션 경로 (위 §3) — exchange 로 sealed
lm_space_impersonation_session진입. 배너 O. passwordMustChange/재동의 체크는 불필요(운영사가 진입). plan·storeCount 는 sealed 세션에 없어 null/0 폴백(topbar 는 0-backend-call 렌더). - 실 HQ_MANAGER 경로 —
apps/space직접 로그인으로lm_space_session(role=HQ_MANAGER) 보유 후/admin진입.loadMeRefreshAware(pmc 가드) →loadHqMeRefreshAware(본사 요약) 로 세션을 관리. 배너 X. apps/admin의 본사 모드 셸(/admin)은 실 HQ_MANAGER 직접 로그인 전용으로 남는다(임퍼소네이션 분기 제거). auth-flow 상 유효 경로지만 운영사 임퍼소네이션의 도착지는 더 이상 admin 이 아니다.
5. 역할별 인가 경계 (SPEC #049)
backend 는 URL prefix 매처로 role 별 1차 인가 경계를 긋는다(SecurityConfig). 1차를 통과해도 본인
조회·테넌트 스코핑 service 는 PrincipalScopeGuard 로 토큰 claim 을 DB(OperatorAccount)와 재검증한다 —
claim 은 진실의 단일 소스가 아니다(토큰 발급 후 정지·회수·role/소속 변경·위변조 가능, backend.md #11).
| prefix 매처 | 1차 경계 (SecurityConfig) | 대표 endpoint |
|---|---|---|
/api/v1/admin/** | hasRole("OPERATOR") | 본사·매장·운영자·티켓·감사·약관·음원 관리 |
/api/v1/hq/** | hasRole("HQ_MANAGER") | getHqMe(/api/v1/hq/me) |
/api/v1/store/** | hasRole("STORE_MANAGER") | getStoreMe(/api/v1/store/me) |
role 미일치는 매처가 403, 미인증은 401.
PrincipalScopeGuard.verify(principal) 재검증 항목 (어느 하나라도 어긋나면 403 PRINCIPAL_SCOPE_MISMATCH):
accountId로OperatorAccount재조회 — 미존재 면 403.status == ACTIVE— SUSPENDED·WITHDRAWN 이면 403(login/refresh/impersonation 정책과 일관).- 토큰
role이 DB role 과 일치. - 토큰
hqId·storeId가 DB 컬럼과 정확히 일치 — 타 테넌트 escalation 차단.
본인 조회는 검증된 DB 값으로만 스코핑한다: verifyHqScope() → DB hqId 반환(HQ_MANAGER·hqId 非null 아니면 403), verifyStoreScope() → DB storeId 반환(STORE_MANAGER·storeId 非null 아니면 403). 불일치 상세는 응답에 노출하지 않고 INFO 로그로만 남긴다(고정 문구 — backend.md #10). CurrentPrincipal 추출·스코핑은 Auth Model 참조.
6. 매장 클라이언트(apps/space) BFF·세션 (SPEC #050)
apps/space 는 본사+점장 매장 클라이언트(space.linkmusic.io). apps/admin 과 다른 origin이며
인증/BFF 인프라를 앱별로 복제한다(§D2 — admin 무회귀 + 세션 형상 독립). 복제 시 군더더기 제거:
- cookie 네임스페이스 분리 — 실 로그인 cookie
lm_space_session/ 임퍼소네이션 cookielm_space_impersonation_session(둘 다 admin 의 cookie 와 분리). 로컬 동시 개발 시 간섭 회피. - session 형상 — 실 로그인 + 임퍼소네이션 도착지 필드 재도입(
impersonatedBy·operatorEmail·hqName·getImpersonationSession). 운영사→본사 임퍼소네이션의 도착지가apps/space/admin/*(진짜 본사 기능)로 바뀌면서, 토큰 교환·sealed 봉인이 여기서 일어난다(과거 SPEC #050 에서 제거됐던 것 재도입). middleware.isPublic—/impersonate-exchange(임퍼소네이션 새 탭 도착지) bypass +/admin/*bypass(layout 단독 가드 — 실 로그인 OR 임퍼소네이션 두 세션 검사·둘 다 없으면 server-side/login, fail-closed). public 은/login·/landing·/·/impersonate-exchange·/api/auth/*·/api/backend/*·static.backend.ts—callBackend+ auth helper(login·refresh·logout·me·change-password) +backendHqMe·도메인 helper +backendImpersonateExchange(임퍼소네이션 토큰 교환). 운영사 전용 관리 helper(onboarding·목록·티켓 등)는 제외.- BFF route —
/api/auth/impersonate-exchange(토큰 교환 →lm_space_impersonation_session봉인)·/api/auth/impersonation-exit(세션 destroy) 추가. apps/admin 원본을 space session 으로 포팅.
login role 분기 (admin 과 반대 — §D4)
| role | 매장 클라이언트(apps/space) | 운영사 백오피스(apps/admin) |
|---|---|---|
HQ_MANAGER | /admin (본사 모드 셸) | /admin (실 HQ_MANAGER 셸) |
STORE_MANAGER | /store (점장 모드 · placeholder) | 차단(logout + 안내) |
OPERATOR | 차단(logout + “운영사 콘솔 이용” 안내) | safeNext (운영사 home) |
/admin layout(server)은 role 가드 후 loadHqMeRefreshAware(refresh-aware)로 GET /api/v1/hq/me
(HqMeResponse)를 받아 HQShell topbar 에 본사명·plan(PlanBadge)·매장수(storeCount)를 주입한다(§D5).
passwordMustChange → change-password 화면은 후속(§D4 TODO).
Helper 모듈 (apps/admin)
운영사→본사 임퍼소네이션 도착지가 apps/space 로 이전되면서 apps/admin 의 임퍼소네이션 자산
(getImpersonationSession·backendImpersonateExchange·/api/auth/impersonate-exchange·
/api/auth/impersonation-exit·ImpersonationBanner)은 제거됐다. admin 은 발급
(POST /admin/hq/{id}/impersonate, catch-all proxy)까지만 하고 새 탭(apps/space origin)으로 위임한다.
src/lib/session.ts—getSession()(iron-session 래퍼). 임퍼소네이션 세션·필드 없음(실 로그인 전용)src/lib/refresh.ts—refreshIfNeeded(session)·forceRefresh(session)src/lib/load-me.ts—loadMeRefreshAware(session)— 만료 임박 선제 refresh → backendMe → 401 1회 재시도 → 실패 시 destroy. ops 셸과/admin실 HQ_MANAGER 셸이 공유하는 단일 소스src/lib/backend.ts—callBackend+ auth/도메인 helper. 임퍼소네이션 발급용(impersonate)은 client catch-all 경유라 server helper 없음. 교환용backendImpersonateExchange는 제거(apps/space 로 이전)src/app/api/auth/*— login · logout · refresh · me · change-password BFF (impersonate-exchange·impersonation-exit 제거)src/app/(protected)/hq/impersonation-confirm-dialog.tsx— [전환] 다이얼로그 → 발급 후NEXT_PUBLIC_SPACE_ORIGIN으로window.open(.../impersonate-exchange#token=...)(apps/space 새 탭)src/app/admin/layout.tsx—/admin/*실 HQ_MANAGER 직접 로그인 셸 가드 (세션 없음→/login, OPERATOR→/, HQ_MANAGER→셸). 임퍼소네이션 분기 없음middleware.ts— 보호된 라우트 가드 +/api/*·/admin/*은 bypass (각자 핸들러/layout 가 가드)
Constraints (재구현 Blueprint)
- JWT 는 HttpOnly Cookie 만. UI / 클라이언트 코드에서 토큰 직접 다루지 X.
- BFF route handler 에서:
- 401 →
session.destroy()+ 401 - 5xx → session 유지 + 502
BACKEND_ERROR - 네트워크 도달 실패 → 502
BACKEND_UNREACHABLE - 4xx (인증 외) →
err.status/err.code그대로 surface
- 401 →
- middleware 에서
/api/*는 redirect X — JSON 401 기대. - Server Component 도 refresh-aware (
refreshIfNeeded사전 호출).
References
- SPEC #003 (인증) · #005 (임퍼소네이션) · #006 (BFF · session) · #016 (role 라우팅 · /admin 이중 세션) · #049 (역할별 인가 경계 · me) · #050 (매장 클라이언트 스캐폴드 · apps/space BFF·세션·role 분기)
linkmusic-frontend-space/apps/space/src/lib/·apps/space/middleware.ts.claude/rules/frontend.mdCopilot 반복 지적 패턴 (1~17)linkmusic-frontend-space/apps/admin/src/lib/linkmusic-msa-space-was/.../application/auth/CurrentPrincipal.kt·PrincipalScopeGuard.kt(#049)linkmusic-msa-space-was/.../api/hq/HqMeController.kt·api/store/StoreMeController.kt(#049)