ArchitectureAuth Flow (login · refresh · impersonation)

Auth Flow (login · refresh · impersonation)

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

Overview

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

  1. Login — 이메일 + 비밀번호 → access (30min) + refresh (14d) → sealed cookie
  2. Refresh — access 만료 임박 또는 401 → refresh 사용 → 새 토큰 rotation
  3. Impersonation — 운영사 OPERATOR → 본사 모드로 대신 진입 (새 탭 + 빨간 배너)

1. Login Flow

1-1. 로그인 rate limit (SPEC #149)

backend /api/v1/auth/login 은 짧은 시간에 반복 실패하는 시도를 429 RATE_LIMITED 로 제한한다(+ Retry-After 헤더). BFF 는 이 응답을 그대로 surface 한다 — 429 는 자격증명 4xx 분기보다 먼저 잡아 “이메일/비밀번호 불일치” 메시지로 덮어쓰지 않고, Retry-After 헤더를 클라이언트로 전달한다. 로그인 폼은 이를 전용 안내(“잠시 후 다시 시도”)로 분기한다(401 자격증명 오류와 구분). 과거 문서·UI 의 “5회 실패 시 15분 잠금” 서술은 실제 동작이 아니며 정정됐다 — 계정 단위 하드 락아웃이 아니라 시도 빈도 기반 일시 제한이다.

IP 신뢰 모델 하드닝(SPEC #151): rate limit 키의 클라이언트 IP 도출은 X-Forwarded-For 위조에 대비해 전략(app.rate-limit.client-ip-strategy)으로 분리된다 — 기본 LEFTMOST(현행 동작·배포 무변경)

  • RIGHTMOST_PUBLIC(오른쪽부터 내부 프록시 IP를 건너뛰고 첫 public IP 채택, 스푸핑 저항). 인프라(Render)는 클라이언트 XFF를 append-only 하므로 leftmost는 위조 가능 — 운영 XFF 체인 검증 후 env 한 줄로 활성화·즉시 롤백한다.

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-0. refresh 실패 분류 — invalid vs transient

lib/refresh.tsforceRefresh/forceRefreshReadOnly 는 boolean 이 아니라 RefreshResult ("refreshed" | "invalid" | "transient") 를 돌려준다. 판정 정책의 단일 소스는 middleware (middleware.ts tryRefresh) 이고 모든 경로가 그것과 동일하게 맞춘다:

refresh 응답결과호출자 동작
200refreshed새 토큰으로 원 요청 1회 재시도
401 · 403invalidsession.destroy() + 401 AUTH_UNAUTHENTICATED/login
5xx · 기타 상태 · 네트워크 실패transient세션 유지 + 502 BACKEND_ERROR (재시도 대상)
200 이지만 쿠키 재봉인(session.save()) 실패invalid즉시 session.destroy() + fail-closed

마지막 행이 중요한 이유 — save() 실패는 BE 가 이미 T0→T1 회전을 커밋한 뒤에 일어난다. 이때 transient 로 분류하면 세션이 유지되고 쿠키에는 revoke 된 T0 가 남아, BE grace (JwtProperties.refreshGrace = 60초) 밖에서 재사용될 때 RefreshTokenReuseGuard계정 전체 세션을 revoke 한다. 그래서 applyAuthbackendRefresh 호출만 실패 분류 대상으로 두고, 토큰 대입 + save() 는 별도 블록에서 다룬다.

왜 중요한가 — 예전에는 refresh 의 모든 예외를 false 하나로 뭉개서 catch-all proxy 가 backend 5xx 만으로도 세션을 파기했다. 그러면 mutator 가 AUTH_UNAUTHENTICATED 를 보고 /login 으로 풀 페이지 이동해, 무인 매장 기기(점장 플레이어)가 로그인 화면에서 멈추고 음악·방송이 끊긴다. 적용 범위: catch-all proxy · /api/auth/me · /api/auth/change-password · RSC 로더(load-me.tstransient/login 대신 “일시 장애 + 재시도 배너”로 렌더).

2-2. proactive refresh 위치 (middleware) 와 /api/backend/*

cookie 재봉인이 가능한 곳은 middleware 와 Route Handler 뿐이라(RSC 는 cookies().set() 금지), 선제 refresh 는 middleware 가 담당한다. /api/backend/*(catch-all BFF proxy)는 redirect 만 bypass 하고 proactive refresh 는 수행한다 — 점장 플레이어(/store)는 화면 전환이 전부 모달이라 페이지 네비게이션이 사실상 없어서, 이 분기가 없으면 access TTL 만료마다 “backend 401 → proxy 반응형 refresh” 왕복이 유일한 갱신 경로가 된다(매장당 하루 수십 회, 그 창에서 큐·방송 호출이 실패).

  • refresh 성공 → 새 sealed cookie 를 request·response 양쪽에 실어 같은 요청의 route handler (getSession())가 새 토큰을 읽게 한다.
  • refresh 실패(무효·회전 race 포함) → redirect 도 cookie 삭제도 하지 않는다. 병렬 XHR 이 같은 refresh token 으로 경쟁할 때 진 쪽이 멀쩡한 세션을 지우면 안 되기 때문이며, 권위 있는 판정은 proxy handler 의 반응형 401 경로가 내린다.
  • ⚠️ 이 회복은 BE 의 refresh grace 에 의존한다. 경쟁에서 진 쪽은 이미 회전된 T0 를 보내는데, BE JwtProperties.refreshGrace(현재 60초) 안이면 RefreshTokenReuseGuard 가 이를 공격으로 보지 않고 통과시킨다. grace 를 줄이거나 없애면 이 경로가 깨진다 — 병렬 refresh 의 진 쪽이 계정 전체 세션 revoke 를 유발한다. BE 값 변경 시 apps/space/middleware.ts 의 대응 주석과 /api/backend/* 분기(세션 미삭제 정책)를 함께 재검토할 것.

2-3. catch-all proxy 의 CSRF 동일 출처 검사

/api/backend/*모든 상태 변경이 지나가는 단일 관문이라, /api/auth/* 핸들러와 동일하게 assertSameOrigin(scheme+host+port 일치) 을 적용한다 — GET·HEAD 는 면제, 그 외 메서드 (POST·PUT·PATCH·DELETE·OPTIONS)는 필수. 세션 조회보다 먼저 검사해 cross-site 요청이 토큰 forward 단계에 도달하지 않게 한다. 무세션 공개 endpoint(PUBLIC_BACKEND_PATHS)도 예외 없이 적용한다. SameSite=Lax 가 이미 대부분을 막지만 심층 방어로 한 겹 더 둔다.

  • Origin 불일치 → 403 CSRF_ORIGIN_MISMATCH / Referer 불일치 → 403 CSRF_REFERER_MISMATCH
  • Origin·Referer 둘 다 없음 → 403 CSRF_NO_ORIGIN / Host 헤더 없음 → 403 CSRF_NO_HOST

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 도달 시 만료 화면으로 전환.
  • 만료 종착 통일 (SPEC #157 · H6) — 임퍼소네이션 만료는 경로와 무관하게 하나의 만료 화면 (ImpersonationExpiredScreen)으로 수렴한다. 두 진입점: (a) 가만히 있다가 클라 카운트다운 0:00 (ImpersonationBanner onExpire → 셸이 만료 화면으로 전환) (b) 만료 후 /admin/* 서브페이지 이동·새로고침 시 middleware 가 임퍼소네이션 쿠키를 unseal 해 refreshExpiresAt 경과를 감지하고 전용 라우트 /impersonation-expiredredirect(앞단 차단). ⚠️ 왜 middleware 인가 — App Router 는 layout 이 {children}(page)을 client 컴포넌트에 prop 으로 넘겨도 그 page 서버 컴포넌트를 조건 분기와 무관하게 서버에서 병렬 렌더한다(RSC payload 직렬화). 따라서 만료 시 셸에서 children 을 “안 보여주는” 것으론 admin page(getActiveSession({scope:"admin"})→만료 토큰 401→redirect("/login"))의 실행·redirect 를 막지 못한다. page 실행 자체를 middleware 앞단에서 차단해야 /login(운영자 자격 없는 space 로그인) dead-end 가 원천 제거된다. 예전엔 정적=만료화면 / 이동=foreign 로그인으로 갈라졌다. 만료 판정은 sealed 세션의 절대 시각만으로 하므로 backend 호출 0 을 유지한다(임퍼소네이션 토큰은 refresh 무의미). 배너 카운트다운과 동일 무마진 정책(now >= refreshExpiresAt, L1). middleware 는 redirect 시 sealed 쿠키를 삭제하고, 만료 라우트 진입 시에도 impersonation-exit 로 재정리한다(stale 재발 방지·belt-and-suspenders) + “이 탭 닫기” close-fail 폴백(운영사 복귀 안내, NEXT_PUBLIC_ADMIN_ORIGIN 있으면 링크). /impersonation-expired 는 me/backend 호출 0 인 자체완결 라우트라 redirect 경쟁이 없다(admin 그룹 밖·isPublic).
  • 세션 분리 — 임퍼소네이션 세션은 apps/space 의 실 로그인 lm_space_session별도 cookie 이름(lm_space_impersonation_session)으로 분리 봉인된다. 같은 브라우저 다른 탭에서 공존.
  • refresh 60min — 일반 14일과 다름. v1 은 고정 60분(자동 refresh 없음).
  • 민감 작업 차단 (SPEC #150) — 임퍼소네이션 세션(impersonatedBy 마커)으로는 계정 주인 본인만 수행해야 하는 민감 작업(비밀번호 변경·이메일 변경 등)을 할 수 없다. backend 가 해당 endpoint 를 403 IMPERSONATION_FORBIDDEN_ACTION 으로 차단한다. 운영사가 본사 신원을 대신 쓰는 동안 자격증명 자체를 바꾸지 못하게 하는 안전장치다.
  • 본사 관리자 계정 복구 경로 (SPEC #158) — 본사 관리자(HQ_MANAGER)가 임시 비밀번호를 분실하거나 설정 메일을 못 받은 경우, 운영사(OPERATOR)/hq/:id 계정 탭 [관리] 에서 복구한다: 비밀번호 재설정(resetHqManagerPassword → 새 임시 비번 + passwordMustChange, 활성 세션 즉시 무효화 → 재로그인 시 강제 변경) 또는 설정메일 재발송(resendHqManagerSetup → setup 토큰 재발급, 이전 미사용 토큰 무효화). 정지/복구/회수도 같은 다이얼로그에서 수행한다. 이 5개 액션은 모두 OPERATOR 전용 + 임퍼소네이션 세션 403 IMPERSONATION_FORBIDDEN_ACTION — 위장 상태에서 본사 계정의 자격증명·라이프사이클을 조작할 수 없다. 점장(STORE_MANAGER) 복구 경로(#029)의 대칭.
  • 데이터 경로 세션 선택은 스코프 명시 — catch-all proxy(/api/backend/[...path]) 와 server 로더가 공통 헬퍼 getActiveSession({ scope }) 을 쓴다. scope필수 인자이며 기본값이 없다(새 호출부가 무심코 임퍼소네이션 우선순위를 상속하는 사고 차단).
    • scope: "impersonation-aware" — 운영사가 본사를 대신해 수행할 수 있는 데이터. layout 과 동일 우선순위(임퍼소네이션 세션 있으면 그 토큰, 없으면 실 로그인)라 배너 신원과 forward 토큰 신원이 일치한다. 임퍼소네이션 토큰은 access·refresh 둘 다 60분이라 401 시 refresh 없이 세션 destroy + 401(SESSION_EXPIRED) 로 fail-closed(refreshIfNeeded/forceRefreshimpersonatedBy 마커로 임퍼소네이션 세션을 건너뛴다).
    • scope: "real-login"임퍼소네이션 cookie 를 읽지 않고 실 로그인 세션만 쓴다.
    • 스코프 이름을 경로(admin/store)가 아니라 세션 선택 규칙으로 부르는 것은 의도적이다. 예전 이름은 “점장 경로만 실 세션” 이라는 잘못된 멘탈 모델을 만들었다(아래 §3-1 참조).

3-1. catch-all proxy 의 스코프 판정 — 화이트리스트 반전

/api/backend/[...path] 는 대상 backend 경로로 스코프를 정한다. 판정은 “임퍼소네이션이 유효한 경로만 등록하고 나머지는 전부 실 로그인” 이라는 화이트리스트 반전이다(경로 나열 방식은 점장 화면이 쓰는 비-store 경로가 조용히 임퍼소네이션 신원을 채택하는 구멍을 남겼다).

backend 경로스코프근거
api/v1/hq/**impersonation-aware본사 기능 = 임퍼소네이션의 목적. 배너 신원 = forward 토큰 신원.
api/v1/auth/me (정확 일치)impersonation-aware본사 모드 셸 계정 메뉴(HqAccountMenu)가 위장 대상 신원을 표시해야 한다.
api/v1/store/**real-login점장 전용 네임스페이스. 본사 토큰이 실리면 hasRole("STORE_MANAGER") 403.
api/v1/legal/**real-loginbackend 가 임퍼소네이션 세션에 403 IMPERSONATION_FORBIDDEN_ACTION → admin 스코프일 이유가 없다.
api/v1/auth/** (me 외) · 그 밖의 모든 경로real-login기본값이 안전한 쪽. 신규 경로는 등록 없이는 절대 임퍼소네이션 신원을 받지 않는다.
  • 점장 셸의 신원 출처는 generated useGetMe() 가 아니라 BFF /api/auth/me (apps/space/src/lib/use-me.tsuseMe)다. BFF 핸들러가 getSession() 으로 실 로그인 세션에 고정돼 있어, auth/me 를 proxy 화이트리스트에 남겨도 점장 화면 신원이 오염되지 않는다. StoreAccountMenu·/store/profile 이름 편집이 이 훅을 쓴다 — generated 훅을 쓰면 임퍼소네이션 대상 본사 관리자의 email·name 이 점장 화면에 표시되고(PII), 그 이름이 프리필된 채 store/me 로 저장돼 타 계정 이름이 점장 계정에 기록된다.
  • 재동의 경로가 실 세션인 근거/onboarding/reagree 서버 페이지 자체가 getSession() 으로 실 세션을 요구한다(임퍼소네이션은 동의 주체가 아니다). 데이터 경로도 같은 세션이어야 약관 개정 직후 임퍼소네이션 cookie 가 남은 단말에서 점장 재동의가 403 으로 영구 차단되지 않는다.
  • 경로 순회 차단 — 판정 전에 catch-all 세그먼트를 검증해 .·..·빈 세그먼트가 있으면 400 INVALID_PATH 로 거부한다. encodeURIComponent. 를 인코딩하지 않아 .. 가 그대로 통과하고 fetch 의 WHATWG URL 파서가 정규화하므로, 검증이 없으면 api/v1/hq/../store/queue스코프는 hq 로 판정되고 실제 요청은 store 로 나갈 수 있다. 스코프 판정과 target URL 은 반드시 같은 검증 결과에서 파생한다(apps/admin proxy 도 동일 방어).

4. apps/space /admin 진입 두 경로

본사 모드 셸(apps/space /admin/*)은 두 세션 모드를 모두 해석한다. app/admin/layout.tsx 가 유일한 가드이며(middleware 는 /admin/* 를 bypass), 우선순위는 임퍼소네이션 우선.

  • 임퍼소네이션 경로 (위 §3) — exchange 로 sealed lm_space_impersonation_session 진입. 배너 O. passwordMustChange/재동의 체크는 불필요(운영사가 진입 — ⓪ early-return 으로 me 로드·재동의 게이트 이전에 분기). plan·storeCount 는 sealed 세션에 없어 null/0 폴백(topbar 는 0-backend-call 렌더). 이 분기는 유효(비만료) 임퍼소네이션만 도달한다 — 만료 세션은 middleware 가 /impersonation-expired 로 앞단 차단하므로 layout·admin page 에 닿지 않는다(SPEC #157 · H6, 위 §3 “만료 종착 통일”).
  • 약관 재동의 게이트 (SPEC #156) — 실 세션 경로에서 pmc 통과 후 termsRequiresAgreement || privacyRequiresAgreement/onboarding/reagree 로 redirect(hq/me 호출 전). HQ_MANAGER·STORE_MANAGER 공통. 임퍼소네이션은 위처럼 제외. Legal Model 참조.
  • 실 HQ_MANAGER 경로apps/space 직접 로그인으로 lm_space_session(role=HQ_MANAGER) 보유 후 /admin 진입. loadMeRefreshAware(pmc 가드) → loadHqMeRefreshAware(본사 요약) 로 세션을 관리. 배너 X.
  • apps/admin 의 본사 모드 셸(/admin)은 SPEC #150 에서 제거됐다. 운영사 앱은 OPERATOR 전용이며 본사 기능의 단일 소스는 apps/space 다. 실 HQ_MANAGER 가 운영사 도메인으로 직접 로그인하는 것은 유효 경로가 아니다 — 로그인 폼이 space.linkmusic.io 로 안내하고 세션을 destroy 하며, protected layout 도 OPERATOR 외 전부 /login 으로 fail-closed 한다.
  • 강제 비밀번호 변경 관문 탈출구(감사 H4, 양 앱)/onboarding/change-password 는 셸을 닫을 수 없는 관문이지만, 임시 비번을 잊었거나 다른 계정으로 로그인하려는 사용자가 갇히지 않도록 두 탈출 경로를 제공한다: [임시 비밀번호를 잊으셨나요?] → 공개 /forgot-password(middleware public — pmc 가드 무관 navigable), [다른 계정으로 로그인]/api/auth/logout(세션 destroy) 후 /login. 강제 변경 자체는 그대로 강제된다.

4-1. 점장(/store) 첫 진입 파이프라인 (SPEC #156)

점장은 두 진입 경로 — setup 메일 링크(/setup?token=, 약관 동의 O·온보딩 X)와 임시비번 직접 공유(로그인 → pmc → 비번 변경 → /store 직행)다. 후자가 약관 동의·온보딩을 모두 건너뛰던 갭을 SPEC #156 이 게이트 2개로 닫는다:

  • 약관 게이트(server)app/store/layout.tsx 가 pmc 통과 후 me 플래그로 /onboarding/reagree redirect. reagree page 는 me.role 로 복귀 목적지(/store)를 도출.
  • 온보딩 게이트(client)app/store/store-entry-gate.tsx/store 진입 시 매장 id 확정 후 localStorage 완료 플래그(done)를 확인해 미완료면 /store/onboarding redirect. 완료 플래그는 마지막 start 화면(start-client)이 기록. BE 백드(onboardingCompletedAt·이력)는 후속 SPEC(B-2). 세션당 리다이렉트 1회 제한 + localStorage 지속성 마커(쓴 값이 다음 진입까지 남는가 + 지금 쓸 수 있는가) + 하드 실링(3회) 으로 무한 루프만 fail-open 한다(저장이 멀쩡하면 2회째여도 온보딩으로 보낸다 — Store Env Check §게이트 참조).
  • 온보딩 완료 플래그는 기기별(POS 단일 기기라 무해). Store Env Check 참조.

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 참조.

5-1. 계정 발급 권한의 경계 (SPEC #184 D4·D5)

계정을 만드는 권한은 오래 OPERATOR 전용이었다. SPEC #184 가 처음으로 HQ_MANAGER 에게 자기 산하 매장의 점장(STORE_MANAGER) 계정 발급을 연다. 열린 범위는 정확히 두 지점이고, 그 밖의 계정 라이프사이클은 여전히 운영사 전용이다.

액션주체endpoint
매장 등록과 동시 점장 발급OPERATOR · HQ_MANAGERonboardIndependentStore·onboardAffiliatedStore(운영사) · createHqStore·bulkCreateHqStores(본사)
점장 사후 발급OPERATOR · HQ_MANAGERissueStoreManager(운영사) · issueHqStoreManager(본사 — 신규)
점장 정지·복구·회수·비밀번호 재설정OPERATOR 전용suspendStoreManager·reactivateStoreManager·revokeStoreManager·resetStoreManagerPassword
계정 설정(setup) 메일 재발송OPERATOR 전용resendAccountSetup·resendHqManagerSetup
본사 관리자·운영자 계정 발급OPERATOR 전용registerHq·운영자 관리

경계 유지 장치 3가지:

  1. 스코프 재검증issueHqStoreManagerPrincipalScopeGuard 로 대상 매장이 자기 본사 산하인지 확인하고, 타 본사·미존재 매장을 403 PRINCIPAL_SCOPE_MISMATCH 로 은닉한다 (404 로 나누면 매장 ID 유효성이 새어 나간다).
  2. 감사 — 새로 여는 권한은 처음부터 기록되게 한다(D5). 발급마다 HqAuditAction.HQ_STORE_MANAGER_ISSUED + HqAuditTargetType.STORE 1행. 임퍼소네이션이면 actorRole=OPERATOR_IMPERSONATING + impersonatedByEmail 동시 기록.
  3. 열거 차단 — 이 경로가 HQ_MANAGER 에게 열리면서 임의 이메일의 409/201 관측만으로 플랫폼 전체 계정을 열거할 수 있게 됐다(실패한 매장은 D9 로 롤백돼 흔적도 없다). 그래서 DUPLICATE_EMAIL 응답 메시지에서 입력 이메일을 제거하고 고정 문구로 바꿨으며(상세는 INFO 로그), 메일을 유발하는 endpoint 전부에 rate limit 을 붙였다(STORE_PROVISION 20/분 · CSV 일괄은 BULK_PROVISION 3/분).

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·/impersonation-expired·/api/auth/*·/api/backend/*·static.
  • middleware 임퍼소네이션 만료 앞단 차단 (SPEC #157 · H6)/admin/* 요청에서 임퍼소네이션 쿠키(lm_space_impersonation_session)를 unsealSession 해 상태를 판정한다: expired(refreshExpiresAt 경과·또는 누락) → /impersonation-expired 로 redirect + sealed 쿠키 삭제(admin page 서버 컴포넌트가 실행되기 전에 앞단 차단해 /login 낙착 제거), active → 통과(layout 채택·refresh 무), invalid → 실 세션 로직. 배너 카운트다운과 동일 무마진(now >= refreshExpiresAt, L1)·backend 호출 0.
  • 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 (본사 모드 셸)차단(logout + “본사는 space.linkmusic.io” 링크 안내) — SPEC #150
STORE_MANAGER/store (점장 모드 · placeholder)차단(logout + “운영사 직원 전용” 안내)
OPERATOR차단(logout + “운영사 콘솔 이용” 안내)safeNext (운영사 home)

SPEC #150 — apps/adminOPERATOR 전용이다. 과거의 본사 모드 placeholder 셸(/admin, 6카드 “준비 중”)은 제거됐다. 본사 기능 단일 소스는 apps/space. 운영사→본사 임퍼소네이션(apps/space 핸드오프)은 무관하게 유지된다.

/admin layout(server)은 role 가드 후 loadHqMeRefreshAware(refresh-aware)로 GET /api/v1/hq/me (HqMeResponse)를 받아 HQShell topbar 에 본사명·plan(PlanBadge)·매장수(storeCount)를 주입한다(§D5). passwordMustChange → change-password, 그다음 terms/privacyRequiresAgreement → reagree 게이트는 hq/me 로드 전에 적용된다(위 §4 flowchart · SPEC #156).

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. OPERATOR 전용 ops 셸 + onboarding 가드가 공유하는 단일 소스 (SPEC #150 — /admin 본사 셸 제거)
  • src/lib/backend.tscallBackend + auth/도메인 helper. 임퍼소네이션 발급용(impersonate)은 client catch-all 경유라 server helper 없음. 교환용 backendImpersonateExchange 는 제거(apps/space 로 이전). BackendCallErrorRetry-After(429) 헤더 원문도 캡처 → login BFF 가 surface
  • src/app/api/auth/login/route.ts — 자격 검증·세션 봉인. 429 RATE_LIMITED 는 전용 분기로 surface(Retry-After 헤더 보존, 자격증명 메시지로 덮어쓰지 않음 — SPEC #149)
  • 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 새 탭) — 임퍼소네이션은 SPEC #150 과 무관하게 유지
  • src/app/(auth)/login/login-form.tsx — OPERATOR 외 role 은 세션 destroy + 차단 안내(HQ_MANAGER → NEXT_PUBLIC_SPACE_ORIGIN 로그인 링크). /admin redirect 없음 (SPEC #150)
  • src/app/(protected)/layout.tsx — OPERATOR 전용 ops 셸 가드. OPERATOR 외 전부 /login fail-closed (HQ_MANAGER /admin 분기 제거 — SPEC #150)
  • middleware.ts — 보호된 라우트 가드 + /api/* 은 bypass (핸들러가 가드)

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 분기) · #149 (로그인 rate limit · 429 RATE_LIMITED) · #150 (운영사 앱 OPERATOR 전용화 · 임퍼소네이션 민감 작업 403) · #151 (rate limit IP 신뢰 모델 하드닝 — client-ip-strategy 플래그·기본 LEFTMOST) · #156 (점장 첫 진입 약관·온보딩 게이트 · space reagree · 임퍼소네이션 제외) · #157 (임퍼소네이션 만료 종착 통일 — 만료 후 서브페이지 이동 시 만료 화면 낙착·/login foreign 로그인 dead-end 차단 · H6)
  • 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)