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:
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
66
packages/auth-adapter/src/core/helpers.ts
Normal file
66
packages/auth-adapter/src/core/helpers.ts
Normal 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;
|
||||
}
|
||||
5
packages/auth-adapter/src/core/index.ts
Normal file
5
packages/auth-adapter/src/core/index.ts
Normal 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";
|
||||
59
packages/auth-adapter/src/core/types.ts
Normal file
59
packages/auth-adapter/src/core/types.ts
Normal 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>;
|
||||
}
|
||||
42
packages/auth-adapter/src/dev-bypass/index.ts
Normal file
42
packages/auth-adapter/src/dev-bypass/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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";
|
||||
|
||||
28
packages/auth-adapter/src/next-auth/augment.d.ts
vendored
Normal file
28
packages/auth-adapter/src/next-auth/augment.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
24
packages/auth-adapter/src/next-auth/handlers.ts
Normal file
24
packages/auth-adapter/src/next-auth/handlers.ts
Normal 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;
|
||||
}
|
||||
83
packages/auth-adapter/src/next-auth/index.ts
Normal file
83
packages/auth-adapter/src/next-auth/index.ts
Normal 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;
|
||||
100
packages/auth-adapter/src/next-auth/keycloak.ts
Normal file
100
packages/auth-adapter/src/next-auth/keycloak.ts
Normal 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",
|
||||
),
|
||||
];
|
||||
}
|
||||
48
packages/auth-adapter/src/next-auth/middleware.ts
Normal file
48
packages/auth-adapter/src/next-auth/middleware.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user