feat(auth-adapter,bff-kit,client-shell): 마지막 추출 배치

@yakcloud/auth-adapter: core 인터페이스(프레임워크 무관, ds-sdk type-only)
  + dev-bypass + next-auth 격리 계층(peer). exports 서브패스(./core·./dev-bypass).
@yakcloud/bff-kit: respond(web 표준)·serialize(Prisma-free)·ratelimit·session(AuthAdapter DI).
  auth-adapter=devDependency(type-only), 죽은 next peer 제거.
@yakcloud/client-shell: ServiceClientShell 셸 크롬(헤더/탭/pane/가드)+패널 슬롯+BackupPanel 스텁.
  (FileBrowser 는 Phase 1)

FIX-FIRST 반영: client-shell tsconfig jsx:react-jsx + @types/react(-dom),
  bff-kit/auth-adapter += @types/node. 7개 패키지 전부 tsc --noEmit 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-25 13:12:34 +09:00
parent e34a325b52
commit 362e3168a7
37 changed files with 2583 additions and 14 deletions

View File

@ -10,6 +10,14 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./core": {
"types": "./dist/core/index.d.ts",
"import": "./dist/core/index.js"
},
"./dev-bypass": {
"types": "./dist/dev-bypass/index.d.ts",
"import": "./dist/dev-bypass/index.js"
}
},
"files": [
@ -26,5 +34,8 @@
},
"peerDependencies": {
"next-auth": ">=5"
},
"devDependencies": {
"@types/node": "^20"
}
}

View File

@ -0,0 +1,66 @@
// @yakcloud/auth-adapter — core/helpers
// 순수 함수. ⛔ next-auth / next / prisma 정적 import 금지. ds-sdk 는 type-only.
import { CODE_STATUS } from "@yakcloud/ds-sdk";
import type { ApiCode } from "@yakcloud/ds-sdk";
import type { AuthAdapter, Session, User } from "./types";
/**
* 인증/인가 실패를 나타내는 프레임워크 무관 에러.
* bff-kit 의 respond.handle() 이 code → HTTP status 로 변환한다(ds-sdk CODE_STATUS 재사용).
*/
export class AuthError extends Error {
readonly code: ApiCode;
readonly status: number;
constructor(code: ApiCode, message?: string) {
super(message ?? code);
this.name = "AuthError";
this.code = code;
this.status = CODE_STATUS[code];
}
}
/**
* 요청에서 세션을 해석한다. 미인증이면 null.
* 어댑터 구현(next-auth 등)은 DI 로 주입된다.
*/
export async function getSession<C, S extends Session>(
req: Request,
adapter: AuthAdapter<C, S>,
ctx?: C,
): Promise<S | null> {
return adapter.resolveSession(req, ctx);
}
/**
* 세션 → 포털 User 로 승격하는 리졸버.
* ⚠️ auth-adapter 는 Repo 를 모른다: consuming app(yakconsole) 이
* getSessionUser() (Repo 조회/JIT upsert)를 이 시그니처로 주입한다.
*/
export type UserResolver<S extends Session = Session> = (
session: S,
) => Promise<User | null>;
/**
* 인증된 포털 User 를 반환하거나 throw.
* - 세션 없음/User 없음 → UNAUTHORIZED
* - status === "SUSPENDED" → FORBIDDEN
*
* @param req Fetch API Request.
* @param adapter 세션 해석 어댑터(DI).
* @param resolve 세션 → 포털 User 리졸버(DI; Repo 접근은 여기 안에서).
* @param ctx 어댑터별 컨텍스트(선택).
*/
export async function requireUser<C, S extends Session>(
req: Request,
adapter: AuthAdapter<C, S>,
resolve: UserResolver<S>,
ctx?: C,
): Promise<User> {
const session = await getSession(req, adapter, ctx);
if (!session) throw new AuthError("UNAUTHORIZED");
const user = await resolve(session);
if (!user) throw new AuthError("UNAUTHORIZED");
if (user.status === "SUSPENDED") throw new AuthError("FORBIDDEN", "정지된 계정입니다.");
return user;
}

View File

@ -0,0 +1,5 @@
// @yakcloud/auth-adapter — core
// 프레임워크 무관 계약 재export. 런타임 의존 0 (ds-sdk 는 type/const 리프).
export type { AuthAdapter, Session, User, Role, UserStatus } from "./types";
export { AuthError, getSession, requireUser } from "./helpers";
export type { UserResolver } from "./helpers";

View File

@ -0,0 +1,59 @@
// @yakcloud/auth-adapter — core/types
// 프레임워크 무관 인증 계약. ⛔ next-auth / next / prisma 정적 import 금지.
// 여기 있는 것은 순수 타입뿐이며 런타임 코드는 없다.
/** 사용자 역할. Keycloak realm_access.roles → 정규화. */
export type Role = "USER" | "ADMIN";
/** 포털 사용자 계정 상태. */
export type UserStatus = "ACTIVE" | "SUSPENDED";
/**
* Session = IdP(JWT) 관점의 최소 세션 뷰.
* 클라이언트에 노출되는 표면은 최소화한다: access_token/refresh_token 은 절대 포함하지 않는다.
* (§4.4 kubeconfig 교환은 서버에서 getToken() 으로만 원 토큰을 읽는다.)
*/
export interface Session {
/** Keycloak subject (sub) — Repo 사용자 조회 키. */
readonly sub: string;
readonly email?: string;
readonly displayName?: string;
readonly role: Role;
/**
* 서버 전용(선택). 클라이언트 세션에는 노출하지 않는다.
* getToken() 을 통해서만 채워지는 백채널 액세스 토큰.
*/
readonly accessToken?: string;
/** refresh 실패 등 오류 신호(예: "RefreshAccessTokenError"). */
readonly error?: string;
}
/**
* User = 포털(Repo) 사용자 모델의 프레임워크 무관 뷰.
* ⚠️ Prisma 모델이 아니다 — 필요한 필드만 가진 구조적 인터페이스.
* 실제 Repo 조회/upsert 는 consuming app(yakconsole) 에서 수행한다.
*/
export interface User {
readonly id: string;
readonly email: string;
readonly displayName: string;
readonly role: Role;
readonly status: UserStatus;
}
/**
* AuthAdapter<C, S> = 교체 가능한 세션 해석기.
* - C: 어댑터별 컨텍스트(예: 쿠키 저장소 핸들). 기본 unknown.
* - S: 어댑터가 반환하는 세션 형태(기본 Session).
*
* bff-kit / middleware 는 이 인터페이스만 알고, 구체 구현(next-auth 등)은 주입(DI)받는다.
* ⛔ 어떤 패키지도 auth-adapter 구현체를 정적 import 하지 않는다.
*/
export interface AuthAdapter<C = unknown, S extends Session = Session> {
/**
* 요청에서 세션을 해석한다. 미인증이면 null.
* @param req Fetch API 표준 Request.
* @param ctx 어댑터별 컨텍스트(선택).
*/
resolveSession(req: Request, ctx?: C): Promise<S | null>;
}

View File

@ -0,0 +1,42 @@
// @yakcloud/auth-adapter — dev-bypass
// IdP(Keycloak) 가 없을 때만 활성화되는 mock 세션 제공자.
// ⛔ IdP/Keycloak/next-auth 정적 import 금지. consuming app 에서 조건부로만 사용.
import type { Role, Session, User } from "../core/types";
/**
* DEV-BYPASS: KEYCLOAK_ISSUER 가 없을 때만 true.
* 어떤 환경이든 KEYCLOAK_ISSUER 를 설정하면 결정적으로 false (잊을 수 있는 별도 opt-in 플래그 없음).
*/
export const DEV_BYPASS: boolean = !process.env.KEYCLOAK_ISSUER;
/** dev-bypass 전용 개발 신원(seed 와 이메일 일치). */
export interface DevIdentity {
readonly id: string;
readonly keycloakSub: string;
readonly email: string;
readonly displayName: string;
readonly role: Role;
}
export const DEV_USER: DevIdentity = {
id: "usr_dev",
keycloakSub: "dev-sub-yakenator",
email: "yakenator@gmail.com",
displayName: "최정우",
role: "USER",
};
/**
* mock 세션 팩토리. DevIdentity(또는 포털 User) 로부터 Session 을 만든다.
* consuming app 의 getSessionUser() 가 DEV_BYPASS 분기에서 사용.
*/
export function createDevBypassSession(user: DevIdentity | User): Session {
const sub = "keycloakSub" in user ? user.keycloakSub : user.id;
return {
sub,
email: user.email,
displayName: user.displayName,
role: user.role,
};
}

View File

@ -1,4 +1,24 @@
// @yakcloud/auth-adapter
// 인증 어댑터(대부분 신규): IdP 인터페이스, auth.ts 씨앗. ⛔ 클라 패키지 정적 import 금지.
// B1 스켈레톤 — 실제 추출은 후속(scaffold-input.md 판정표 참조).
export const __package = "@yakcloud/auth-adapter";
// 인증 어댑터: (1) core = 프레임워크 무관 계약, (2) dev-bypass = mock 세션,
// (3) next-auth = Next.js + Keycloak 격리 구현(peer: next-auth).
//
// ⚠️ 이 배럴은 next-auth 팩토리도 재export 하므로, 이 모듈을 통째로 import 하면
// next-auth 를 정적 로드한다. 프레임워크 무관 소비자(bff-kit 등)는
// "@yakcloud/auth-adapter/core" / "/dev-bypass" 서브패스로만 import 할 것.
// (bff-kit 은 AuthAdapter 를 DI 로 받고 이 패키지를 deps 에 넣지 않는다.)
// ── core (framework-free) ──
export type { AuthAdapter, Session, User, Role, UserStatus, UserResolver } from "./core";
export { AuthError, getSession, requireUser } from "./core";
// ── dev-bypass (opt-in mock; IdP 미가용 시) ──
export { DEV_BYPASS, DEV_USER, createDevBypassSession } from "./dev-bypass";
export type { DevIdentity } from "./dev-bypass";
// ── next-auth (⚠ peer: next-auth >=5 필요) ──
export { createAuthConfig, auth, handlers, signIn, signOut } from "./next-auth";
export type { AuthConfigOptions } from "./next-auth";
export { createAuthMiddleware, matcher } from "./next-auth/middleware";
export { getToken } from "./next-auth/handlers";
export type { KeycloakToken } from "./next-auth/keycloak";
export { makeKeycloak, keycloakProviders, refreshKeycloakToken } from "./next-auth/keycloak";

View File

@ -0,0 +1,28 @@
// @yakcloud/auth-adapter — next-auth 모듈 보강(augmentation)
// session 콜백이 노출하는 최소 필드(keycloakSub, role, error) 와
// JWT 에 보관하는 Keycloak 토큰 필드의 타입을 next-auth 에 주입한다.
// ⚠️ next-auth 하위 구현에만 관계 — core/dev-bypass 는 이 파일과 무관.
import "next-auth";
import "next-auth/jwt";
declare module "next-auth" {
interface Session {
user: {
keycloakSub?: string;
role?: "USER" | "ADMIN";
} & import("next-auth").DefaultSession["user"];
error?: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
access_token?: string;
refresh_token?: string;
id_token?: string;
expires_at?: number;
role?: "USER" | "ADMIN";
error?: string;
}
}

View File

@ -0,0 +1,24 @@
// @yakcloud/auth-adapter — next-auth/handlers
// Next.js app 용 라우트 핸들러 재export + 서버측 Keycloak 토큰 접근(getToken).
// next-auth/jwt 는 peerDependency.
import { getToken as nextAuthGetToken } from "next-auth/jwt";
import type { KeycloakToken } from "./keycloak";
export { auth, handlers, signIn, signOut } from "./index";
/**
* 서버 라우트에서 Keycloak 토큰(access/refresh/id)을 직접 읽는다.
* §4.4 kubeconfig 교환처럼 원 access_token 이 필요할 때만 사용(클라이언트엔 노출 금지).
*
* @param req Fetch API Request(App Router route handler 의 req).
* @param secret AUTH_SECRET (기본 process.env.AUTH_SECRET).
*/
export async function getToken(
req: Request,
secret = process.env.AUTH_SECRET,
): Promise<KeycloakToken | null> {
if (!secret) return null;
const token = await nextAuthGetToken({ req, secret });
return (token as KeycloakToken | null) ?? null;
}

View File

@ -0,0 +1,83 @@
// @yakcloud/auth-adapter — next-auth
// NextAuth 초기화 격리 계층. next-auth 는 peerDependency (consuming app 이 제공).
// ⚠️ 이 모듈을 import 하면 next-auth 를 정적 로드한다 — dev-bypass/core 만 필요하면 import 하지 말 것.
/// <reference path="./augment.d.ts" />
import NextAuth from "next-auth";
import type { NextAuthConfig, NextAuthResult } from "next-auth";
import { keycloakProviders, refreshKeycloakToken } from "./keycloak";
import type { KeycloakToken } from "./keycloak";
import { DEV_BYPASS } from "../dev-bypass";
/** createAuthConfig 옵션(선택 DI). */
export interface AuthConfigOptions {
/** 로그인 페이지 경로(기본 "/login"). */
signInPath?: string;
/** 추가/오버라이드 config(edge-sync 바인딩 등 후속 DI 지점). */
overrides?: Partial<NextAuthConfig>;
}
/**
* NextAuthConfig 팩토리.
* - DEV_BYPASS 면 providers=[] (로그인 불가; consuming app 이 mock 세션 사용).
* - JWT 전략(세션 테이블 없음, design §3 note 1).
* - jwt 콜백: Keycloak 토큰 보관 + 만료 임박 시 회전.
* - session 콜백: 최소·비민감 필드만(keycloakSub, role, error) 노출.
*/
export function createAuthConfig(options: AuthConfigOptions = {}): NextAuthConfig {
const { signInPath = "/login", overrides } = options;
const providers = DEV_BYPASS ? [] : keycloakProviders();
const config: NextAuthConfig = {
trustHost: true,
session: { strategy: "jwt" },
providers,
pages: { signIn: signInPath },
callbacks: {
// Keycloak 토큰을 암호화 JWT 에 보관(§4.4 kubeconfig 교환용); 만료 임박 시 회전.
async jwt({ token, account, profile }) {
const t = token as KeycloakToken;
if (account) {
t.access_token = account.access_token as string | undefined;
t.refresh_token = account.refresh_token as string | undefined;
t.id_token = account.id_token as string | undefined;
t.expires_at = account.expires_at as number | undefined; // epoch seconds
if (profile?.sub) t.sub = profile.sub;
const roles =
(profile as { realm_access?: { roles?: string[] } } | null)?.realm_access?.roles ?? [];
t.role = roles.includes("admin") ? "ADMIN" : "USER";
return t;
}
if (t.expires_at && Date.now() < (Number(t.expires_at) - 60) * 1000) {
return t;
}
if (t.refresh_token) return refreshKeycloakToken(t);
return t;
},
// 클라이언트 세션엔 최소·비민감 필드만(access_token/refresh_token 노출 금지 — §4.4 은 getToken 으로 서버에서만).
async session({ session, token }) {
const t = token as KeycloakToken;
if (session.user) {
session.user.keycloakSub = t.sub as string;
session.user.role = t.role ?? "USER";
}
session.error = t.error;
return session;
},
},
...overrides,
};
return config;
}
/**
* 기본 config 로 NextAuth 를 초기화한 결과.
* consuming app 은 이 재export(handlers/auth/signIn/signOut)를 그대로 쓰거나,
* createAuthConfig(...) 로 커스텀 config 를 만들어 직접 NextAuth() 를 호출해도 된다.
*/
const nextAuth: NextAuthResult = NextAuth(createAuthConfig());
export const handlers = nextAuth.handlers;
export const auth = nextAuth.auth;
export const signIn = nextAuth.signIn;
export const signOut = nextAuth.signOut;

View File

@ -0,0 +1,100 @@
// @yakcloud/auth-adapter — next-auth/keycloak
// Keycloak provider 팩토리 + refresh 콜백. next-auth 는 peerDependency.
// 이 하위 디렉터리(src/next-auth/*)에만 next-auth 의존을 격리한다.
import Keycloak from "next-auth/providers/keycloak";
import type { Provider } from "next-auth/providers";
// 프론트채널(브라우저: authorize·iss) vs 백채널(서버: discovery·token·userinfo·jwks).
// 도커 등에서 두 경로의 호스트가 다를 때 KEYCLOAK_INTERNAL_ISSUER 로 백채널을 분리한다.
// 프로덕션 단일 호스트면 미설정 → 프론트=백=KEYCLOAK_ISSUER (일반 동작).
const KC_FRONT = process.env.KEYCLOAK_ISSUER;
const KC_BACK = process.env.KEYCLOAK_INTERNAL_ISSUER ?? KC_FRONT;
/** JWT 콜백이 다루는 최소 토큰 형태. */
export interface KeycloakToken {
sub?: string;
role?: "USER" | "ADMIN";
access_token?: string;
refresh_token?: string;
id_token?: string;
expires_at?: number;
error?: string;
[key: string]: unknown;
}
/** Keycloak refresh_token 회전(백채널 사용). */
export async function refreshKeycloakToken(token: KeycloakToken): Promise<KeycloakToken> {
try {
const secret = process.env.KEYCLOAK_CLIENT_SECRET;
if (!secret) throw new Error("KEYCLOAK_CLIENT_SECRET not set");
const res = await fetch(`${KC_BACK}/protocol/openid-connect/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: process.env.KEYCLOAK_CLIENT_ID ?? "yakconsole",
client_secret: secret,
refresh_token: String(token.refresh_token),
}),
});
const t = (await res.json()) as {
access_token?: string;
expires_in?: number;
refresh_token?: string;
id_token?: string;
};
if (!res.ok) throw t;
return {
...token,
access_token: t.access_token,
expires_at: Math.floor(Date.now() / 1000) + Number(t.expires_in ?? 0),
refresh_token: t.refresh_token ?? token.refresh_token,
id_token: t.id_token ?? token.id_token,
error: undefined,
};
} catch (e) {
console.error("[auth] refresh failed", e);
return { ...token, error: "RefreshAccessTokenError" };
}
}
/**
* authorizePath 만 다른 provider 를 만든다:
* - "keycloak": 로그인(/auth)
* - "keycloak-register": 회원가입(/registrations) — /signup 이 등록 폼으로 직행
*/
export function makeKeycloak(id: string, name: string, authorizePath: string): Provider {
const base = {
id,
name,
clientId: process.env.KEYCLOAK_CLIENT_ID ?? "yakconsole",
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
issuer: KC_FRONT, // iss 검증·기본 discovery
};
// 백채널이 프론트와 같으면 authorize 경로만 지정, 나머지는 discovery.
if (KC_BACK === KC_FRONT) {
return Keycloak({ ...base, authorization: `${KC_FRONT}${authorizePath}` });
}
// 분리 시: discovery·token·userinfo·jwks 는 백채널, authorize 만 프론트(브라우저).
return Keycloak({
...base,
wellKnown: `${KC_BACK}/.well-known/openid-configuration`,
authorization: `${KC_FRONT}${authorizePath}`,
token: `${KC_BACK}/protocol/openid-connect/token`,
userinfo: `${KC_BACK}/protocol/openid-connect/userinfo`,
jwks_endpoint: `${KC_BACK}/protocol/openid-connect/certs`,
});
}
/** 로그인 + 회원가입 두 provider. dev-bypass 시 빈 배열을 쓰라. */
export function keycloakProviders(): Provider[] {
return [
makeKeycloak("keycloak", "YakCloud", "/protocol/openid-connect/auth"),
makeKeycloak(
"keycloak-register",
"YakCloud 회원가입",
"/protocol/openid-connect/registrations",
),
];
}

View File

@ -0,0 +1,48 @@
// @yakcloud/auth-adapter — next-auth/middleware
// 라우트 보호 미들웨어 팩토리. AuthAdapter 를 주입받아 Fetch Request 로 세션을 판정한다.
// next-auth 정적 import 없음(어댑터 DI). next/server 는 응답 헬퍼용 peer.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { CODE_STATUS } from "@yakcloud/ds-sdk";
import type { AuthAdapter, Session } from "../core/types";
/** (console) 라우트 + /api/v1 매칭. /api/v1/internal/* 는 미들웨어 내부에서 우회. */
export const matcher: string[] = ["/api/v1/:path*", "/((?!_next|login|signup|manual).*)"];
/**
* 미들웨어 팩토리. AuthAdapter 를 주입받는다.
* - /api/v1/internal/* → 세션 검사 우회(서비스 토큰은 핸들러에서 검증).
* - /api/v1/* → 미인증이면 401 JSON.
* - 그 외(console) → 미인증이면 /login 리다이렉트.
*/
export function createAuthMiddleware<C, S extends Session>(
adapter: AuthAdapter<C, S>,
loginPath = "/login",
) {
return async function middleware(req: NextRequest): Promise<NextResponse> {
const { pathname } = req.nextUrl;
// /internal/* 는 서비스 토큰(세션 아님)으로 게이트 — 미들웨어 통과.
if (pathname.startsWith("/api/v1/internal/")) {
return NextResponse.next();
}
const session = await adapter.resolveSession(req);
if (session) return NextResponse.next();
// /api/v1 → 401 JSON.
if (pathname.startsWith("/api/v1")) {
return NextResponse.json(
{ ok: false, error: { code: "UNAUTHORIZED" } },
{ status: CODE_STATUS.UNAUTHORIZED },
);
}
// (console) → /login 리다이렉트(원래 경로 callbackUrl 보존).
const url = req.nextUrl.clone();
url.pathname = loginPath;
url.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(url);
};
}