ArchitectureAuth Flow (login · refresh · impersonation)

Auth Flow (login · refresh · impersonation)

SPEC #003 · #005 · #006 정합.

Overview

세 가지 핵심 인증 흐름이 있다:

  1. Login — 이메일 + 비밀번호 → access (15min) + refresh (7d) → sealed cookie
  2. Refresh — access 만료 임박 또는 401 → refresh 사용 → 새 토큰 rotation
  3. 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 는 기존대로 ApiError throw → 각 화면이 처리.
  • 루프 가드 — 이미 /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 의 원 OPERATOR lm_session 은 admin 탭에서 그대로 유지. 새 탭은 NEXT_PUBLIC_SPACE_ORIGIN(클라이언트 노출 env, apps/admin) 으로 조립한 절대 URL 로 연다. 미설정이면 [전환] 이 에러를 surface 하고 탭을 열지 않는다(조용한 무산 방지).
  • fragment 토큰#token=... 은 cross-origin window.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/forceRefreshimpersonatedBy 마커로 임퍼소네이션 세션을 건너뛴다). 점장(/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):

  1. accountIdOperatorAccount 재조회 — 미존재 면 403.
  2. status == ACTIVE — SUSPENDED·WITHDRAWN 이면 403(login/refresh/impersonation 정책과 일관).
  3. 토큰 role 이 DB role 과 일치.
  4. 토큰 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 / 임퍼소네이션 cookie lm_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.tscallBackend + 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.tsgetSession() (iron-session 래퍼). 임퍼소네이션 세션·필드 없음(실 로그인 전용)
  • src/lib/refresh.tsrefreshIfNeeded(session) · forceRefresh(session)
  • src/lib/load-me.tsloadMeRefreshAware(session) — 만료 임박 선제 refresh → backendMe → 401 1회 재시도 → 실패 시 destroy. ops 셸과 /admin 실 HQ_MANAGER 셸이 공유하는 단일 소스
  • src/lib/backend.tscallBackend + 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
  • 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.md Copilot 반복 지적 패턴 (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)