From 5bbcc88a8f325596fd5d433ac699752f376a6c66 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Mon, 20 Apr 2026 16:49:47 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=EB=B3=84=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8A=A4=ED=82=A8=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client_skins 테이블 추가 (OIDC/SAML 클라이언트별, 스킨 타입별) - R2 Private 기반 TTL 캐시 + fetch 실패 시 기본 스킨 fallback - X-IDP-Token 헤더로 스킨 URL 보호, Content-Type HTML 검증 - 치환자: {{IDP_FORM_ACTION}}, {{IDP_REDIRECT_TO}}, {{IDP_SKIN_HINT}} - OIDC/SAML 로그인 리다이렉트에 skinHint 파라미터 추가 - 관리자 스킨 관리 페이지 (생성/삭제/활성화/캐시삭제) - wrangler.jsonc R2 바인딩(SKIN_CACHE) 추가 (gitignore로 별도 적용 필요) Co-Authored-By: Claude Sonnet 4.6 --- src/app.d.ts | 2 +- src/lib/i18n/ko.json | 28 +++- src/lib/server/db/schema.ts | 24 +++ src/lib/server/skin/resolver.ts | 111 +++++++++++++ src/routes/(auth)/login/+page.server.ts | 29 +++- src/routes/(auth)/login/+page.svelte | 178 +++++++++++---------- src/routes/admin/+layout.svelte | 1 + src/routes/admin/skins/+page.server.ts | 134 ++++++++++++++++ src/routes/admin/skins/+page.svelte | 199 ++++++++++++++++++++++++ src/routes/oidc/authorize/+server.ts | 1 + src/routes/saml/sso/+server.ts | 3 + 11 files changed, 625 insertions(+), 85 deletions(-) create mode 100644 src/lib/server/skin/resolver.ts create mode 100644 src/routes/admin/skins/+page.server.ts create mode 100644 src/routes/admin/skins/+page.svelte diff --git a/src/app.d.ts b/src/app.d.ts index 850a2f7..04b831d 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -3,7 +3,7 @@ declare global { namespace App { interface Platform { - env: Env; + env: Env & { SKIN_CACHE?: R2Bucket }; ctx: ExecutionContext; caches: CacheStorage; cf?: IncomingRequestCfProperties; diff --git a/src/lib/i18n/ko.json b/src/lib/i18n/ko.json index cf3c9df..d3c55c9 100644 --- a/src/lib/i18n/ko.json +++ b/src/lib/i18n/ko.json @@ -54,7 +54,8 @@ "departments": "부서", "teams": "팀", "parts": "파트", - "ldap_providers": "LDAP 프로바이더" + "ldap_providers": "LDAP 프로바이더", + "skins": "로그인 스킨" }, "account": { "profile": "내 프로필", @@ -353,5 +354,30 @@ "poc": { "title": "Workers 환경 PoC", "subtitle": "킥오프 M0 사전 점검. 각 엔드포인트는 GET 요청 시 JSON 으로 결과를 반환." + }, + "skins": { + "title": "로그인 스킨", + "add_btn": "+ 스킨 추가", + "create_title": "새 로그인 스킨", + "created_success": "스킨이 추가되었습니다.", + "cache_invalidated": "캐시가 삭제되었습니다.", + "client_type_label": "클라이언트 타입", + "client_label": "클라이언트", + "fetch_url_label": "스킨 URL", + "fetch_secret_label": "인증 시크릿 (X-IDP-Token)", + "fetch_secret_placeholder": "없으면 비워두세요", + "cache_ttl_label": "캐시 TTL (초)", + "col_client": "클라이언트", + "col_skin_type": "스킨 타입", + "col_fetch_url": "스킨 URL", + "col_ttl": "TTL", + "col_created": "생성", + "empty": "등록된 스킨이 없습니다.", + "delete_confirm": "스킨을 삭제하시겠습니까?", + "invalidate_cache": "캐시 삭제", + "placeholder_guide_title": "치환자 안내", + "placeholder_form_action": "폼 action (비어 있으면 현재 URL로 POST)", + "placeholder_redirect_to": "로그인 후 리다이렉트 대상 URL", + "placeholder_skin_hint": "스킨 힌트 값 (hidden input에 활용)" } } diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 6ba326a..cd6618c 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -662,6 +662,29 @@ export const rateLimits = sqliteTable("rate_limits", { expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), }); +export const clientSkins = sqliteTable( + "client_skins", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + tenantId: text("tenant_id") + .notNull() + .references(() => tenants.id, { onDelete: "cascade" }), + clientType: text("client_type", { enum: ["oidc", "saml"] }).notNull(), + clientRefId: text("client_ref_id").notNull(), + skinType: text("skin_type", { enum: ["login"] }).notNull().default("login"), + fetchUrl: text("fetch_url").notNull(), + fetchSecret: text("fetch_secret"), + cacheTtlSeconds: integer("cache_ttl_seconds").notNull().default(3600), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + }, + (t) => [uniqueIndex("client_skins_unique").on(t.tenantId, t.clientType, t.clientRefId, t.skinType)], +); + export type User = typeof users.$inferSelect; export type Credential = typeof credentials.$inferSelect; export type Identity = typeof identities.$inferSelect; @@ -682,3 +705,4 @@ export type UserTeam = typeof userTeams.$inferSelect; export type Part = typeof parts.$inferSelect; export type UserPart = typeof userParts.$inferSelect; export type WebauthnChallenge = typeof webauthnChallenges.$inferSelect; +export type ClientSkin = typeof clientSkins.$inferSelect; diff --git a/src/lib/server/skin/resolver.ts b/src/lib/server/skin/resolver.ts new file mode 100644 index 0000000..e735d72 --- /dev/null +++ b/src/lib/server/skin/resolver.ts @@ -0,0 +1,111 @@ +import type { DB } from "$lib/server/db"; +import { clientSkins } from "$lib/server/db/schema"; +import { and, eq } from "drizzle-orm"; + +const R2_PREFIX = "skins/"; + +export function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function replacePlaceholders(html: string, vars: Record): string { + let result = html; + for (const [key, value] of Object.entries(vars)) { + result = result.replaceAll(`{{${key}}}`, value); + } + return result; +} + +export async function resolveSkinHtml( + db: DB, + platform: App.Platform | undefined, + tenantId: string, + clientType: "oidc" | "saml", + clientRefId: string, + skinType: "login" = "login", +): Promise { + const [skin] = await db + .select() + .from(clientSkins) + .where( + and( + eq(clientSkins.tenantId, tenantId), + eq(clientSkins.clientType, clientType), + eq(clientSkins.clientRefId, clientRefId), + eq(clientSkins.skinType, skinType), + eq(clientSkins.enabled, true), + ), + ) + .limit(1); + + if (!skin) return null; + + const r2 = (platform?.env as Record | undefined)?.SKIN_CACHE as R2Bucket | undefined; + const cacheKey = `${R2_PREFIX}${tenantId}/${clientType}/${clientRefId}/${skinType}`; + + if (r2) { + try { + const cached = await r2.get(cacheKey); + if (cached) { + const fetchedAt = Number(cached.customMetadata?.fetchedAt ?? 0); + if (Date.now() - fetchedAt < skin.cacheTtlSeconds * 1000) { + return await cached.text(); + } + } + } catch { + // R2 오류는 무시하고 원본 fetch로 진행 + } + } + + try { + const headers: Record = { Accept: "text/html" }; + if (skin.fetchSecret) { + headers["X-IDP-Token"] = skin.fetchSecret; + } + + const res = await fetch(skin.fetchUrl, { headers }); + if (!res.ok) return null; + + const contentType = res.headers.get("Content-Type") ?? ""; + if (!contentType.includes("text/html")) return null; + + const html = await res.text(); + + if (r2) { + try { + await r2.put(cacheKey, html, { + customMetadata: { fetchedAt: String(Date.now()) }, + httpMetadata: { contentType: "text/html; charset=utf-8" }, + }); + } catch { + // 캐시 저장 실패는 무시 + } + } + + return html; + } catch { + return null; + } +} + +export async function invalidateSkinCache( + platform: App.Platform | undefined, + tenantId: string, + clientType: "oidc" | "saml", + clientRefId: string, + skinType: "login" = "login", +): Promise { + const r2 = (platform?.env as Record | undefined)?.SKIN_CACHE as R2Bucket | undefined; + if (!r2) return; + const cacheKey = `${R2_PREFIX}${tenantId}/${clientType}/${clientRefId}/${skinType}`; + try { + await r2.delete(cacheKey); + } catch { + // 무시 + } +} diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts index 82a3a61..6fca643 100644 --- a/src/routes/(auth)/login/+page.server.ts +++ b/src/routes/(auth)/login/+page.server.ts @@ -14,6 +14,7 @@ import { authenticateLdap } from "$lib/server/ldap/auth"; import { provisionLdapUser } from "$lib/server/ldap/provision"; import type { LdapProviderConfig } from "$lib/server/ldap/types"; import { decryptSecret } from "$lib/server/crypto/keys"; +import { resolveSkinHtml, replacePlaceholders, escapeHtml } from "$lib/server/skin/resolver"; function sanitizeRedirectTarget(target: string | null): string | null { if (!target) return null; @@ -30,15 +31,39 @@ function sanitizeRedirectTarget(target: string | null): string | null { return target; } -export const load: PageServerLoad = async ({ locals, url }) => { +export const load: PageServerLoad = async ({ locals, url, platform }) => { // forceAuthn=true 이면 이미 로그인된 사용자도 재인증을 진행해야 하므로 자동 리다이렉트 생략 const forceAuthn = url.searchParams.get("forceAuthn") === "true"; if (locals.user && !forceAuthn) { throw redirect(302, locals.user.role === "admin" ? "/admin" : "/"); } + const redirectTo = sanitizeRedirectTarget(url.searchParams.get("redirectTo")); + const skinHint = url.searchParams.get("skinHint"); + let skinHtml: string | null = null; + + if (skinHint && locals.db && locals.tenant) { + const colonIdx = skinHint.indexOf(":"); + if (colonIdx > 0) { + const clientType = skinHint.slice(0, colonIdx) as "oidc" | "saml"; + const clientRefId = skinHint.slice(colonIdx + 1); + if ((clientType === "oidc" || clientType === "saml") && clientRefId) { + const raw = await resolveSkinHtml(locals.db, platform, locals.tenant.id, clientType, clientRefId); + if (raw) { + skinHtml = replacePlaceholders(raw, { + IDP_FORM_ACTION: "", + IDP_REDIRECT_TO: escapeHtml(redirectTo ?? ""), + IDP_SKIN_HINT: escapeHtml(skinHint), + }); + } + } + } + } + return { - redirectTo: sanitizeRedirectTarget(url.searchParams.get("redirectTo")), + redirectTo, + skinHint, + skinHtml, dbReady: Boolean(locals.db), runtimeError: locals.runtimeError, }; diff --git a/src/routes/(auth)/login/+page.svelte b/src/routes/(auth)/login/+page.svelte index a4107b2..a691011 100644 --- a/src/routes/(auth)/login/+page.svelte +++ b/src/routes/(auth)/login/+page.svelte @@ -48,95 +48,111 @@ async function loginWithPasskey() { } -
-
-
-

{t("app.title")}

-

M0 관리자 진입용 로컬 계정 로그인입니다.

+{#if data.skinHtml} + + {#if !data.dbReady && data.runtimeError} +
+ {data.runtimeError}
- - {#if !data.dbReady && data.runtimeError} -
- {data.runtimeError} + {/if} + {#if form?.error} +
+ {form.error} +
+ {/if} + {@html data.skinHtml} +{:else} + +
+
+
+

{t("app.title")}

+

M0 관리자 진입용 로컬 계정 로그인입니다.

- {/if} - {#if form?.error} -
- {form.error} -
- {/if} + {#if !data.dbReady && data.runtimeError} +
+ {data.runtimeError} +
+ {/if} - {#if passkeyError} -
- {passkeyError} -
- {/if} - -
- - -
- - -
+ {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if passkeyError} +
+ {passkeyError} +
+ {/if} -
- - + + + +
+ + +
+ +
+ + +
+ + + + +
+
+ 또는 +
- - -
-
- 또는 -
- -
-
+{/if} diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 1ebb750..f059d84 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -14,6 +14,7 @@ const menuItems = [ { route: "/admin/saml-sps" as const, key: "admin.saml_sps" }, { route: "/admin/signing-keys" as const, key: "admin.signing_keys" }, { route: "/admin/ldap-providers" as const, key: "admin.ldap_providers" }, + { route: "/admin/skins" as const, key: "admin.skins" }, { route: "/admin/audit" as const, key: "admin.audit" }, ]; diff --git a/src/routes/admin/skins/+page.server.ts b/src/routes/admin/skins/+page.server.ts new file mode 100644 index 0000000..0691156 --- /dev/null +++ b/src/routes/admin/skins/+page.server.ts @@ -0,0 +1,134 @@ +import { fail } from "@sveltejs/kit"; +import { desc, eq, and } from "drizzle-orm"; +import type { Actions, PageServerLoad } from "./$types"; +import { requireAdminContext } from "$lib/server/auth/guards"; +import { clientSkins, oidcClients, samlSps } from "$lib/server/db/schema"; +import { invalidateSkinCache } from "$lib/server/skin/resolver"; + +export const load: PageServerLoad = async ({ locals }) => { + const { db, tenant } = requireAdminContext(locals); + + const [skins, oidcList, samlList] = await Promise.all([ + db + .select() + .from(clientSkins) + .where(eq(clientSkins.tenantId, tenant.id)) + .orderBy(desc(clientSkins.createdAt)), + db + .select({ id: oidcClients.id, name: oidcClients.name, clientId: oidcClients.clientId }) + .from(oidcClients) + .where(eq(oidcClients.tenantId, tenant.id)), + db + .select({ id: samlSps.id, name: samlSps.name, entityId: samlSps.entityId }) + .from(samlSps) + .where(eq(samlSps.tenantId, tenant.id)), + ]); + + return { skins, oidcList, samlList }; +}; + +export const actions: Actions = { + create: async ({ request, locals }) => { + const { db, tenant } = requireAdminContext(locals); + const fd = await request.formData(); + + const clientType = fd.get("clientType") as "oidc" | "saml"; + const clientRefId = String(fd.get("clientRefId") ?? "").trim(); + const skinType = "login" as const; + const fetchUrl = String(fd.get("fetchUrl") ?? "").trim(); + const fetchSecret = String(fd.get("fetchSecret") ?? "").trim() || null; + const cacheTtlSeconds = Number(fd.get("cacheTtlSeconds") ?? 3600); + + if (!clientType || !clientRefId || !fetchUrl) { + return fail(400, { create: true, error: "필수 항목을 입력해 주세요." }); + } + if (clientType !== "oidc" && clientType !== "saml") { + return fail(400, { create: true, error: "clientType이 올바르지 않습니다." }); + } + + let url: URL; + try { + url = new URL(fetchUrl); + } catch { + return fail(400, { create: true, error: "유효한 URL을 입력해 주세요." }); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + return fail(400, { create: true, error: "http 또는 https URL만 허용됩니다." }); + } + + try { + await db.insert(clientSkins).values({ + tenantId: tenant.id, + clientType, + clientRefId, + skinType, + fetchUrl, + fetchSecret, + cacheTtlSeconds: isNaN(cacheTtlSeconds) ? 3600 : cacheTtlSeconds, + enabled: true, + }); + } catch { + return fail(409, { create: true, error: "이미 동일한 클라이언트/스킨 타입 설정이 있습니다." }); + } + + return { created: true }; + }, + + delete: async ({ request, locals, platform }) => { + const { db, tenant } = requireAdminContext(locals); + const fd = await request.formData(); + const id = String(fd.get("id") ?? ""); + + const [skin] = await db + .select() + .from(clientSkins) + .where(and(eq(clientSkins.id, id), eq(clientSkins.tenantId, tenant.id))) + .limit(1); + + if (!skin) return fail(404, { error: "스킨을 찾을 수 없습니다." }); + + await invalidateSkinCache(platform, tenant.id, skin.clientType, skin.clientRefId, skin.skinType); + await db.delete(clientSkins).where(eq(clientSkins.id, id)); + + return { deleted: true }; + }, + + toggleEnabled: async ({ request, locals }) => { + const { db, tenant } = requireAdminContext(locals); + const fd = await request.formData(); + const id = String(fd.get("id") ?? ""); + + const [skin] = await db + .select() + .from(clientSkins) + .where(and(eq(clientSkins.id, id), eq(clientSkins.tenantId, tenant.id))) + .limit(1); + + if (!skin) return fail(404, { error: "스킨을 찾을 수 없습니다." }); + + await db + .update(clientSkins) + .set({ enabled: !skin.enabled }) + .where(eq(clientSkins.id, id)); + + return { toggled: true }; + }, + + invalidateCache: async ({ request, locals, platform }) => { + const { db, tenant } = requireAdminContext(locals); + const fd = await request.formData(); + const id = String(fd.get("id") ?? ""); + + const [skin] = await db + .select() + .from(clientSkins) + .where(and(eq(clientSkins.id, id), eq(clientSkins.tenantId, tenant.id))) + .limit(1); + + if (!skin) return fail(404, { error: "스킨을 찾을 수 없습니다." }); + + await invalidateSkinCache(platform, tenant.id, skin.clientType, skin.clientRefId, skin.skinType); + + return { invalidated: true }; + }, +}; diff --git a/src/routes/admin/skins/+page.svelte b/src/routes/admin/skins/+page.svelte new file mode 100644 index 0000000..eec430b --- /dev/null +++ b/src/routes/admin/skins/+page.svelte @@ -0,0 +1,199 @@ + + +
+
+

{t("skins.title")}

+ +
+ + {#if globalErr} +
{globalErr}
+ {/if} + + {#if (form as { created?: boolean } | null)?.created} +
{t("skins.created_success")}
+ {/if} + + {#if (form as { invalidated?: boolean } | null)?.invalidated} +
{t("skins.cache_invalidated")}
+ {/if} + + {#if showCreate} +
+

{t("skins.create_title")}

+ {#if createErr} +
{createErr}
+ {/if} +
+ ({ result, update }) => { + update(); + if (result.type === "success") showCreate = false; + }} + class="grid grid-cols-1 gap-3 sm:grid-cols-2"> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ {/if} + +
+ + + + + + + + + + + + + + {#if data.skins.length === 0} + + + + {:else} + {#each data.skins as skin (skin.id)} + + + + + + + + + + {/each} + {/if} + +
{t("skins.col_client")}{t("skins.col_skin_type")}{t("skins.col_fetch_url")}{t("skins.col_ttl")}{t("common.status")}{t("skins.col_created")}{t("common.actions")}
{t("skins.empty")}
+ {CLIENT_TYPE_LABEL[skin.clientType]} + {clientLabel(skin.clientType, skin.clientRefId)} + {skin.skinType}{skin.fetchUrl}{skin.cacheTtlSeconds}s + + {skin.enabled ? t("common.status_active") : t("common.status_inactive")} + + {dateFormatter.format(skin.createdAt)} +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+

{t("skins.placeholder_guide_title")}

+

{{IDP_FORM_ACTION}} — {t("skins.placeholder_form_action")}

+

{{IDP_REDIRECT_TO}} — {t("skins.placeholder_redirect_to")}

+

{{IDP_SKIN_HINT}} — {t("skins.placeholder_skin_hint")}

+
+
diff --git a/src/routes/oidc/authorize/+server.ts b/src/routes/oidc/authorize/+server.ts index d618e11..6e27a83 100644 --- a/src/routes/oidc/authorize/+server.ts +++ b/src/routes/oidc/authorize/+server.ts @@ -76,6 +76,7 @@ export const GET: RequestHandler = async (event) => { if (!locals.user || !locals.session) { const loginUrl = new URL("/login", url); loginUrl.searchParams.set("redirectTo", url.pathname + url.search); + loginUrl.searchParams.set("skinHint", `oidc:${client.id}`); throw redirect(302, loginUrl.toString()); } diff --git a/src/routes/saml/sso/+server.ts b/src/routes/saml/sso/+server.ts index 37e2556..db86941 100644 --- a/src/routes/saml/sso/+server.ts +++ b/src/routes/saml/sso/+server.ts @@ -103,6 +103,7 @@ export const GET: RequestHandler = async (event) => { if (!locals.user || !locals.session) { const loginUrl = new URL("/login", url); loginUrl.searchParams.set("redirectTo", url.pathname + url.search); + loginUrl.searchParams.set("skinHint", `saml:${sp.id}`); throw redirect(302, loginUrl.toString()); } @@ -111,6 +112,7 @@ export const GET: RequestHandler = async (event) => { if (authnRequest.forceAuthn && locals.session.createdAt < authnRequest.issueInstant) { const loginUrl = new URL("/login", url); loginUrl.searchParams.set("redirectTo", url.pathname + url.search); + loginUrl.searchParams.set("skinHint", `saml:${sp.id}`); loginUrl.searchParams.set("forceAuthn", "true"); throw redirect(302, loginUrl.toString()); } @@ -142,6 +144,7 @@ export const GET: RequestHandler = async (event) => { // 첫 시도: 재인증(MFA 포함)을 강제한다. const loginUrl = new URL("/login", url); loginUrl.searchParams.set("redirectTo", url.pathname + url.search); + loginUrl.searchParams.set("skinHint", `saml:${sp.id}`); loginUrl.searchParams.set("forceAuthn", "true"); throw redirect(302, loginUrl.toString()); } From a7dec91dc7a19200fc281e9435299d71fb0ad67f Mon Sep 17 00:00:00 2001 From: mack-erel <9063075+mack-erel@users.noreply.github.com> Date: Mon, 20 Apr 2026 07:50:35 +0000 Subject: [PATCH 02/18] =?UTF-8?q?style:=20=EC=BD=94=EB=93=9C=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=ED=8F=AC=EB=A7=B7=20=EC=A0=81=EC=9A=A9=20[skip=20c?= =?UTF-8?q?i]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/server/db/schema.ts | 4 +++- src/lib/server/skin/resolver.ts | 15 ++------------- src/routes/admin/skins/+page.server.ts | 21 ++++----------------- src/routes/admin/skins/+page.svelte | 7 ++----- 4 files changed, 11 insertions(+), 36 deletions(-) diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index cd6618c..18ee0ab 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -673,7 +673,9 @@ export const clientSkins = sqliteTable( .references(() => tenants.id, { onDelete: "cascade" }), clientType: text("client_type", { enum: ["oidc", "saml"] }).notNull(), clientRefId: text("client_ref_id").notNull(), - skinType: text("skin_type", { enum: ["login"] }).notNull().default("login"), + skinType: text("skin_type", { enum: ["login"] }) + .notNull() + .default("login"), fetchUrl: text("fetch_url").notNull(), fetchSecret: text("fetch_secret"), cacheTtlSeconds: integer("cache_ttl_seconds").notNull().default(3600), diff --git a/src/lib/server/skin/resolver.ts b/src/lib/server/skin/resolver.ts index e735d72..9d6a6b5 100644 --- a/src/lib/server/skin/resolver.ts +++ b/src/lib/server/skin/resolver.ts @@ -5,12 +5,7 @@ import { and, eq } from "drizzle-orm"; const R2_PREFIX = "skins/"; export function escapeHtml(str: string): string { - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } export function replacePlaceholders(html: string, vars: Record): string { @@ -93,13 +88,7 @@ export async function resolveSkinHtml( } } -export async function invalidateSkinCache( - platform: App.Platform | undefined, - tenantId: string, - clientType: "oidc" | "saml", - clientRefId: string, - skinType: "login" = "login", -): Promise { +export async function invalidateSkinCache(platform: App.Platform | undefined, tenantId: string, clientType: "oidc" | "saml", clientRefId: string, skinType: "login" = "login"): Promise { const r2 = (platform?.env as Record | undefined)?.SKIN_CACHE as R2Bucket | undefined; if (!r2) return; const cacheKey = `${R2_PREFIX}${tenantId}/${clientType}/${clientRefId}/${skinType}`; diff --git a/src/routes/admin/skins/+page.server.ts b/src/routes/admin/skins/+page.server.ts index 0691156..6d93b9e 100644 --- a/src/routes/admin/skins/+page.server.ts +++ b/src/routes/admin/skins/+page.server.ts @@ -9,19 +9,9 @@ export const load: PageServerLoad = async ({ locals }) => { const { db, tenant } = requireAdminContext(locals); const [skins, oidcList, samlList] = await Promise.all([ - db - .select() - .from(clientSkins) - .where(eq(clientSkins.tenantId, tenant.id)) - .orderBy(desc(clientSkins.createdAt)), - db - .select({ id: oidcClients.id, name: oidcClients.name, clientId: oidcClients.clientId }) - .from(oidcClients) - .where(eq(oidcClients.tenantId, tenant.id)), - db - .select({ id: samlSps.id, name: samlSps.name, entityId: samlSps.entityId }) - .from(samlSps) - .where(eq(samlSps.tenantId, tenant.id)), + db.select().from(clientSkins).where(eq(clientSkins.tenantId, tenant.id)).orderBy(desc(clientSkins.createdAt)), + db.select({ id: oidcClients.id, name: oidcClients.name, clientId: oidcClients.clientId }).from(oidcClients).where(eq(oidcClients.tenantId, tenant.id)), + db.select({ id: samlSps.id, name: samlSps.name, entityId: samlSps.entityId }).from(samlSps).where(eq(samlSps.tenantId, tenant.id)), ]); return { skins, oidcList, samlList }; @@ -106,10 +96,7 @@ export const actions: Actions = { if (!skin) return fail(404, { error: "스킨을 찾을 수 없습니다." }); - await db - .update(clientSkins) - .set({ enabled: !skin.enabled }) - .where(eq(clientSkins.id, id)); + await db.update(clientSkins).set({ enabled: !skin.enabled }).where(eq(clientSkins.id, id)); return { toggled: true }; }, diff --git a/src/routes/admin/skins/+page.svelte b/src/routes/admin/skins/+page.svelte index eec430b..8558e39 100644 --- a/src/routes/admin/skins/+page.svelte +++ b/src/routes/admin/skins/+page.svelte @@ -27,10 +27,7 @@ function clientLabel(clientType: string, clientRefId: string): string {

{t("skins.title")}

-
@@ -190,7 +187,7 @@ function clientLabel(clientType: string, clientRefId: string): string {
-
+

{t("skins.placeholder_guide_title")}

{{IDP_FORM_ACTION}} — {t("skins.placeholder_form_action")}

{{IDP_REDIRECT_TO}} — {t("skins.placeholder_redirect_to")}

From 239274ea20a1c57c45729c73d1779780da9d9e49 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Mon, 20 Apr 2026 16:53:00 +0900 Subject: [PATCH 03/18] =?UTF-8?q?docs:=20wrangler.example.jsonc=EC=97=90?= =?UTF-8?q?=20R2=20SKIN=5FCACHE=20=EB=B0=94=EC=9D=B8=EB=94=A9=20=EC=98=88?= =?UTF-8?q?=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- wrangler.example.jsonc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc index 2ed6c15..9438f07 100644 --- a/wrangler.example.jsonc +++ b/wrangler.example.jsonc @@ -36,6 +36,15 @@ // OIDC/SAML 토큰 발급 issuer URL (배포 도메인과 일치시킬 것) "IDP_ISSUER_URL": "https://keystone.example.com" }, + "r2_buckets": [ + { + // 커스텀 로그인 스킨 R2 캐시 버킷 + // 생성: wrangler r2 bucket create keystone-skin-cache + "binding": "SKIN_CACHE", + "bucket_name": "keystone-skin-cache", + "remote": true + } + ], "d1_databases": [ { "binding": "DB", From 7c6dd33ed6f94722392994e0346062f78e7d420b Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Mon, 20 Apr 2026 16:56:08 +0900 Subject: [PATCH 04/18] =?UTF-8?q?fix:=20{@html}=20ESLint=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=20=EC=96=B5=EC=A0=9C=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/routes/(auth)/login/+page.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/(auth)/login/+page.svelte b/src/routes/(auth)/login/+page.svelte index a691011..bb0132a 100644 --- a/src/routes/(auth)/login/+page.svelte +++ b/src/routes/(auth)/login/+page.svelte @@ -60,6 +60,7 @@ async function loginWithPasskey() { {form.error}
{/if} + {@html data.skinHtml} {:else} From e13085a8ca28096afa9448ef5dce4758c6428931 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Mon, 20 Apr 2026 17:02:58 +0900 Subject: [PATCH 05/18] =?UTF-8?q?feat:=20signup/find-id/find-password=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A8=20=ED=83=80=EC=9E=85=20=EB=B0=8F=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client_skins.skinType enum에 signup, find_id, find_password 추가 - /signup, /find-id, /find-password 라우트 생성 - 커스텀 스킨 있으면 렌더, 없으면 기본 준비중 UI 표시 - 관리자 스킨 페이지에서 스킨 타입 선택 가능 Co-Authored-By: Claude Sonnet 4.6 --- src/lib/i18n/ko.json | 17 ++++++++++ src/lib/server/db/schema.ts | 2 +- src/lib/server/skin/resolver.ts | 10 ++++-- src/routes/(auth)/find-id/+page.server.ts | 33 +++++++++++++++++++ src/routes/(auth)/find-id/+page.svelte | 27 +++++++++++++++ .../(auth)/find-password/+page.server.ts | 33 +++++++++++++++++++ src/routes/(auth)/find-password/+page.svelte | 27 +++++++++++++++ src/routes/(auth)/signup/+page.server.ts | 33 +++++++++++++++++++ src/routes/(auth)/signup/+page.svelte | 27 +++++++++++++++ src/routes/admin/skins/+page.server.ts | 2 +- src/routes/admin/skins/+page.svelte | 11 ++++++- 11 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 src/routes/(auth)/find-id/+page.server.ts create mode 100644 src/routes/(auth)/find-id/+page.svelte create mode 100644 src/routes/(auth)/find-password/+page.server.ts create mode 100644 src/routes/(auth)/find-password/+page.svelte create mode 100644 src/routes/(auth)/signup/+page.server.ts create mode 100644 src/routes/(auth)/signup/+page.svelte diff --git a/src/lib/i18n/ko.json b/src/lib/i18n/ko.json index d3c55c9..da69a9b 100644 --- a/src/lib/i18n/ko.json +++ b/src/lib/i18n/ko.json @@ -355,6 +355,18 @@ "title": "Workers 환경 PoC", "subtitle": "킥오프 M0 사전 점검. 각 엔드포인트는 GET 요청 시 JSON 으로 결과를 반환." }, + "signup": { + "title": "회원가입", + "not_available": "현재 이 서비스는 관리자를 통해서만 계정을 생성할 수 있습니다." + }, + "find_id": { + "title": "아이디 찾기", + "not_available": "아이디 찾기 기능은 현재 지원되지 않습니다. 관리자에게 문의해 주세요." + }, + "find_password": { + "title": "비밀번호 찾기", + "not_available": "비밀번호 찾기 기능은 현재 지원되지 않습니다. 관리자에게 문의해 주세요." + }, "skins": { "title": "로그인 스킨", "add_btn": "+ 스킨 추가", @@ -367,6 +379,11 @@ "fetch_secret_label": "인증 시크릿 (X-IDP-Token)", "fetch_secret_placeholder": "없으면 비워두세요", "cache_ttl_label": "캐시 TTL (초)", + "skin_type_label": "스킨 타입", + "skin_type_login": "로그인", + "skin_type_signup": "회원가입", + "skin_type_find_id": "아이디 찾기", + "skin_type_find_password": "비밀번호 찾기", "col_client": "클라이언트", "col_skin_type": "스킨 타입", "col_fetch_url": "스킨 URL", diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 18ee0ab..dbdbce0 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -673,7 +673,7 @@ export const clientSkins = sqliteTable( .references(() => tenants.id, { onDelete: "cascade" }), clientType: text("client_type", { enum: ["oidc", "saml"] }).notNull(), clientRefId: text("client_ref_id").notNull(), - skinType: text("skin_type", { enum: ["login"] }) + skinType: text("skin_type", { enum: ["login", "signup", "find_id", "find_password"] }) .notNull() .default("login"), fetchUrl: text("fetch_url").notNull(), diff --git a/src/lib/server/skin/resolver.ts b/src/lib/server/skin/resolver.ts index 9d6a6b5..6e11417 100644 --- a/src/lib/server/skin/resolver.ts +++ b/src/lib/server/skin/resolver.ts @@ -22,7 +22,7 @@ export async function resolveSkinHtml( tenantId: string, clientType: "oidc" | "saml", clientRefId: string, - skinType: "login" = "login", + skinType: "login" | "signup" | "find_id" | "find_password" = "login", ): Promise { const [skin] = await db .select() @@ -88,7 +88,13 @@ export async function resolveSkinHtml( } } -export async function invalidateSkinCache(platform: App.Platform | undefined, tenantId: string, clientType: "oidc" | "saml", clientRefId: string, skinType: "login" = "login"): Promise { +export async function invalidateSkinCache( + platform: App.Platform | undefined, + tenantId: string, + clientType: "oidc" | "saml", + clientRefId: string, + skinType: "login" | "signup" | "find_id" | "find_password" = "login", +): Promise { const r2 = (platform?.env as Record | undefined)?.SKIN_CACHE as R2Bucket | undefined; if (!r2) return; const cacheKey = `${R2_PREFIX}${tenantId}/${clientType}/${clientRefId}/${skinType}`; diff --git a/src/routes/(auth)/find-id/+page.server.ts b/src/routes/(auth)/find-id/+page.server.ts new file mode 100644 index 0000000..fefcc50 --- /dev/null +++ b/src/routes/(auth)/find-id/+page.server.ts @@ -0,0 +1,33 @@ +import { fail } from "@sveltejs/kit"; +import type { Actions, PageServerLoad } from "./$types"; +import { resolveSkinHtml, replacePlaceholders, escapeHtml } from "$lib/server/skin/resolver"; + +export const load: PageServerLoad = async ({ locals, url, platform }) => { + const skinHint = url.searchParams.get("skinHint"); + let skinHtml: string | null = null; + + if (skinHint && locals.db && locals.tenant) { + const colonIdx = skinHint.indexOf(":"); + if (colonIdx > 0) { + const clientType = skinHint.slice(0, colonIdx) as "oidc" | "saml"; + const clientRefId = skinHint.slice(colonIdx + 1); + if ((clientType === "oidc" || clientType === "saml") && clientRefId) { + const raw = await resolveSkinHtml(locals.db, platform, locals.tenant.id, clientType, clientRefId, "find_id"); + if (raw) { + skinHtml = replacePlaceholders(raw, { + IDP_FORM_ACTION: "", + IDP_SKIN_HINT: escapeHtml(skinHint), + }); + } + } + } + } + + return { skinHint, skinHtml }; +}; + +export const actions: Actions = { + default: async () => { + return fail(501, { error: "아이디 찾기 기능은 아직 구현되지 않았습니다." }); + }, +}; diff --git a/src/routes/(auth)/find-id/+page.svelte b/src/routes/(auth)/find-id/+page.svelte new file mode 100644 index 0000000..6795bae --- /dev/null +++ b/src/routes/(auth)/find-id/+page.svelte @@ -0,0 +1,27 @@ + + +{#if data.skinHtml} + {#if form?.error} +
+ {form.error} +
+ {/if} + + {@html data.skinHtml} +{:else} +
+
+

{t("find_id.title")}

+

{t("find_id.not_available")}

+ + {t("login.submit")} + +
+
+{/if} diff --git a/src/routes/(auth)/find-password/+page.server.ts b/src/routes/(auth)/find-password/+page.server.ts new file mode 100644 index 0000000..c13b460 --- /dev/null +++ b/src/routes/(auth)/find-password/+page.server.ts @@ -0,0 +1,33 @@ +import { fail } from "@sveltejs/kit"; +import type { Actions, PageServerLoad } from "./$types"; +import { resolveSkinHtml, replacePlaceholders, escapeHtml } from "$lib/server/skin/resolver"; + +export const load: PageServerLoad = async ({ locals, url, platform }) => { + const skinHint = url.searchParams.get("skinHint"); + let skinHtml: string | null = null; + + if (skinHint && locals.db && locals.tenant) { + const colonIdx = skinHint.indexOf(":"); + if (colonIdx > 0) { + const clientType = skinHint.slice(0, colonIdx) as "oidc" | "saml"; + const clientRefId = skinHint.slice(colonIdx + 1); + if ((clientType === "oidc" || clientType === "saml") && clientRefId) { + const raw = await resolveSkinHtml(locals.db, platform, locals.tenant.id, clientType, clientRefId, "find_password"); + if (raw) { + skinHtml = replacePlaceholders(raw, { + IDP_FORM_ACTION: "", + IDP_SKIN_HINT: escapeHtml(skinHint), + }); + } + } + } + } + + return { skinHint, skinHtml }; +}; + +export const actions: Actions = { + default: async () => { + return fail(501, { error: "비밀번호 찾기 기능은 아직 구현되지 않았습니다." }); + }, +}; diff --git a/src/routes/(auth)/find-password/+page.svelte b/src/routes/(auth)/find-password/+page.svelte new file mode 100644 index 0000000..d5e1799 --- /dev/null +++ b/src/routes/(auth)/find-password/+page.svelte @@ -0,0 +1,27 @@ + + +{#if data.skinHtml} + {#if form?.error} +
+ {form.error} +
+ {/if} + + {@html data.skinHtml} +{:else} +
+
+

{t("find_password.title")}

+

{t("find_password.not_available")}

+ + {t("login.submit")} + +
+
+{/if} diff --git a/src/routes/(auth)/signup/+page.server.ts b/src/routes/(auth)/signup/+page.server.ts new file mode 100644 index 0000000..ae7f753 --- /dev/null +++ b/src/routes/(auth)/signup/+page.server.ts @@ -0,0 +1,33 @@ +import { fail } from "@sveltejs/kit"; +import type { Actions, PageServerLoad } from "./$types"; +import { resolveSkinHtml, replacePlaceholders, escapeHtml } from "$lib/server/skin/resolver"; + +export const load: PageServerLoad = async ({ locals, url, platform }) => { + const skinHint = url.searchParams.get("skinHint"); + let skinHtml: string | null = null; + + if (skinHint && locals.db && locals.tenant) { + const colonIdx = skinHint.indexOf(":"); + if (colonIdx > 0) { + const clientType = skinHint.slice(0, colonIdx) as "oidc" | "saml"; + const clientRefId = skinHint.slice(colonIdx + 1); + if ((clientType === "oidc" || clientType === "saml") && clientRefId) { + const raw = await resolveSkinHtml(locals.db, platform, locals.tenant.id, clientType, clientRefId, "signup"); + if (raw) { + skinHtml = replacePlaceholders(raw, { + IDP_FORM_ACTION: "", + IDP_SKIN_HINT: escapeHtml(skinHint), + }); + } + } + } + } + + return { skinHint, skinHtml }; +}; + +export const actions: Actions = { + default: async () => { + return fail(501, { error: "회원가입 기능은 아직 구현되지 않았습니다." }); + }, +}; diff --git a/src/routes/(auth)/signup/+page.svelte b/src/routes/(auth)/signup/+page.svelte new file mode 100644 index 0000000..ef56744 --- /dev/null +++ b/src/routes/(auth)/signup/+page.svelte @@ -0,0 +1,27 @@ + + +{#if data.skinHtml} + {#if form?.error} +
+ {form.error} +
+ {/if} + + {@html data.skinHtml} +{:else} +
+
+

{t("signup.title")}

+

{t("signup.not_available")}

+ + {t("login.submit")} + +
+
+{/if} diff --git a/src/routes/admin/skins/+page.server.ts b/src/routes/admin/skins/+page.server.ts index 6d93b9e..200cec3 100644 --- a/src/routes/admin/skins/+page.server.ts +++ b/src/routes/admin/skins/+page.server.ts @@ -24,7 +24,7 @@ export const actions: Actions = { const clientType = fd.get("clientType") as "oidc" | "saml"; const clientRefId = String(fd.get("clientRefId") ?? "").trim(); - const skinType = "login" as const; + const skinType = (fd.get("skinType") ?? "login") as "login" | "signup" | "find_id" | "find_password"; const fetchUrl = String(fd.get("fetchUrl") ?? "").trim(); const fetchSecret = String(fd.get("fetchSecret") ?? "").trim() || null; const cacheTtlSeconds = Number(fd.get("cacheTtlSeconds") ?? 3600); diff --git a/src/routes/admin/skins/+page.svelte b/src/routes/admin/skins/+page.svelte index 8558e39..d1e9e52 100644 --- a/src/routes/admin/skins/+page.svelte +++ b/src/routes/admin/skins/+page.svelte @@ -66,6 +66,15 @@ function clientLabel(clientType: string, clientRefId: string): string {
+
+ + +
+ +
+ + +
+
+ + +
+ + +
+ +`} +
+
+

{t("skins.guide_example_note_title")}

+
    +
  • {t("skins.guide_example_note_1")}
  • +
  • {t("skins.guide_example_note_2")}
  • +
  • {t("skins.guide_example_note_3")}
  • +
+
+ + + +
+

{t("skins.guide_cache_title")}

+
    +
  • {t("skins.guide_cache_1")}
  • +
  • {t("skins.guide_cache_2")}
  • +
  • {t("skins.guide_cache_3")}
  • +
+
+ + +
+

{t("skins.guide_setup_title")}

+
    +
  1. {t("skins.guide_setup_1")}
  2. +
  3. {t("skins.guide_setup_2")}
  4. +
  5. {t("skins.guide_setup_3")}
  6. +
  7. {t("skins.guide_setup_4")}
  8. +
+ + {t("skins.title")} → + +
+
From dd62f8c9a03e33ebded2df80df36c6af26c88e87 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Mon, 20 Apr 2026 17:21:49 +0900 Subject: [PATCH 07/18] =?UTF-8?q?feat:=20setup=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=EC=97=90=20R2=20=EB=B2=84=ED=82=B7=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=8B=A8=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - step4b_r2Setup: wrangler r2 bucket create 실행 후 wrangler.jsonc bucket_name 업데이트 - --r2-bucket-name, --no-r2 CLI 옵션 추가 - 버킷 이미 존재할 경우 경고 후 계속 진행 Co-Authored-By: Claude Sonnet 4.6 --- scripts/setup.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/scripts/setup.ts b/scripts/setup.ts index 9c67090..45edc20 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -51,6 +51,8 @@ interface Args { noPreview: boolean; migrate?: boolean; migratePreview?: boolean; + r2BucketName?: string; + noR2: boolean; signingKey?: string; tenantName?: string; adminUsername?: string; @@ -65,6 +67,7 @@ interface Args { function parseArgs(argv: string[]): Args { const args: Args = { noPreview: false, + noR2: false, yes: false, help: false, }; @@ -99,6 +102,12 @@ function parseArgs(argv: string[]): Args { case "--no-migrate-preview": args.migratePreview = false; break; + case "--r2-bucket-name": + args.r2BucketName = argv[++i]; + break; + case "--no-r2": + args.noR2 = true; + break; case "--signing-key": args.signingKey = argv[++i]; break; @@ -148,6 +157,8 @@ ${cyan("옵션:")} --no-migrate 마이그레이션 건너뜀 --migrate-preview 프리뷰 DB 마이그레이션 자동 진행 --no-migrate-preview 프리뷰 DB 마이그레이션 건너뜀 + --r2-bucket-name R2 버킷 이름 (기본값: keystone-skin-cache) + --no-r2 R2 버킷 생성 건너뜀 --signing-key IDP_SIGNING_KEY_SECRET 값 --tenant-name 조직(테넌트) 이름 --admin-username 초기 관리자 아이디 @@ -588,6 +599,44 @@ async function step4_updateFiles(dbId: string, previewDbId: string | null, accou console.log(green(" ✓ .env 업데이트 완료")); } +async function step4b_r2Setup(args: Args) { + console.log(`\n${cyan("─── 4b. R2 버킷 설정 (스킨 캐시) ─────────────────────────────")}`); + + if (args.noR2) { + console.log(" R2 버킷 생성 건너뜀"); + return; + } + + const doCreate = args.yes || (await confirm("커스텀 스킨 캐시용 R2 버킷을 생성하시겠습니까?", true)); + if (!doCreate) { + console.log(" R2 버킷 생성 건너뜀"); + return; + } + + const bucketName = args.r2BucketName ?? (await ask("R2 버킷 이름", "keystone-skin-cache")); + + const result = await runWithSpinner(`R2 버킷 생성 중: ${bucketName}`, "wrangler", ["r2", "bucket", "create", bucketName]); + if (!result.success) { + const combined = result.stdout + result.stderr; + if (combined.includes("already exists")) { + console.log(yellow(` 버킷 '${bucketName}'이 이미 존재합니다. 기존 버킷을 사용합니다.`)); + } else { + console.error(red(` R2 버킷 생성 실패:\n${result.stderr}`)); + return; + } + } else { + console.log(green(` ✓ R2 버킷 '${bucketName}' 생성 완료`)); + } + + // wrangler.jsonc의 bucket_name 업데이트 + if (fs.existsSync(WRANGLER_JSONC)) { + let content = readFile(WRANGLER_JSONC); + content = replaceAll(content, "keystone-skin-cache", bucketName); + writeFile(WRANGLER_JSONC, content); + console.log(green(` ✓ wrangler.jsonc bucket_name 업데이트 완료`)); + } +} + // ─── Migration Conflict Detection ──────────────────────────────────────────── async function getExistingTables(dbName: string, env: Record): Promise { @@ -1049,6 +1098,9 @@ async function main() { // Step 4: Update files await step4_updateFiles(dbId, previewDbId, accountId); + // Step 4b: R2 bucket + await step4b_r2Setup(args); + // Step 5: Migration await step5_migrate(args, previewDbId !== null, dbName, previewDbName); From b202744d40d9a1a2a0f32e70508a349e3e0f1ca6 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Mon, 20 Apr 2026 17:25:38 +0900 Subject: [PATCH 08/18] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=82=A8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=ED=8F=BC=EC=97=90=EC=84=9C=20clientType?= =?UTF-8?q?=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 클라이언트 선택 시 OIDC/SAML optgroup에서 자동 판별하여 hidden input으로 전송 Co-Authored-By: Claude Sonnet 4.6 --- src/routes/admin/skins/+page.svelte | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/routes/admin/skins/+page.svelte b/src/routes/admin/skins/+page.svelte index c401d0f..5c87660 100644 --- a/src/routes/admin/skins/+page.svelte +++ b/src/routes/admin/skins/+page.svelte @@ -9,6 +9,9 @@ const { data, form } = $props<{ data: PageData; form?: ActionData }>(); const dateFormatter = new Intl.DateTimeFormat("ko-KR", { dateStyle: "medium" }); let showCreate = $state(false); +const firstClientId = data.oidcList[0]?.id ?? data.samlList[0]?.id ?? ""; +let selectedClientRefId = $state(firstClientId); +const derivedClientType = $derived(data.oidcList.some((c: { id: string }) => c.id === selectedClientRefId) ? "oidc" : "saml"); const createErr = $derived((form as { create?: boolean; error?: string } | null)?.create ? ((form as { error?: string } | null)?.error ?? null) : null); const globalErr = $derived(createErr ? null : ((form as { error?: string } | null)?.error ?? null)); @@ -63,13 +66,7 @@ function clientLabel(clientType: string, clientRefId: string): string { if (result.type === "success") showCreate = false; }} class="grid grid-cols-1 gap-3 sm:grid-cols-2"> -
- - -
+
+ +
+ + + {/if} + +
{/if} diff --git a/src/routes/(auth)/find-password/+page.server.ts b/src/routes/(auth)/find-password/+page.server.ts index c13b460..8c4094a 100644 --- a/src/routes/(auth)/find-password/+page.server.ts +++ b/src/routes/(auth)/find-password/+page.server.ts @@ -1,6 +1,14 @@ import { fail } from "@sveltejs/kit"; +import { eq, and } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; import { resolveSkinHtml, replacePlaceholders, escapeHtml } from "$lib/server/skin/resolver"; +import { requireDbContext } from "$lib/server/auth/guards"; +import { users, passwordResetTokens } from "$lib/server/db/schema"; +import { sendPasswordResetEmail, generateToken } from "$lib/server/email"; +import { env } from "$env/dynamic/private"; +import { resolve } from "$app/paths"; + +const RESET_EXPIRY_MS = 60 * 60 * 1000; export const load: PageServerLoad = async ({ locals, url, platform }) => { const skinHint = url.searchParams.get("skinHint"); @@ -27,7 +35,38 @@ export const load: PageServerLoad = async ({ locals, url, platform }) => { }; export const actions: Actions = { - default: async () => { - return fail(501, { error: "비밀번호 찾기 기능은 아직 구현되지 않았습니다." }); + default: async (event) => { + const { db, tenant } = requireDbContext(event.locals); + + const formData = await event.request.formData(); + const email = String(formData.get("email") ?? "") + .trim() + .toLowerCase(); + const username = String(formData.get("username") ?? "") + .trim() + .toLowerCase(); + + if (!email || !username) return fail(400, { error: "이메일과 아이디를 모두 입력해 주세요." }); + + const [user] = await db + .select({ id: users.id, email: users.email }) + .from(users) + .where(and(eq(users.tenantId, tenant.id), eq(users.email, email), eq(users.username, username))) + .limit(1); + + if (user) { + try { + const { token, tokenHash } = await generateToken(); + const expiresAt = new Date(Date.now() + RESET_EXPIRY_MS); + await db.insert(passwordResetTokens).values({ userId: user.id, tokenHash, expiresAt }); + const issuer = env.IDP_ISSUER_URL ?? event.url.origin; + const resetUrl = `${issuer}${resolve("/reset-password")}?token=${token}`; + await sendPasswordResetEmail(user.email, resetUrl); + } catch { + // 조용히 무시 + } + } + + return { sent: true }; }, }; diff --git a/src/routes/(auth)/find-password/+page.svelte b/src/routes/(auth)/find-password/+page.svelte index d5e1799..c3f4b7a 100644 --- a/src/routes/(auth)/find-password/+page.svelte +++ b/src/routes/(auth)/find-password/+page.svelte @@ -4,6 +4,7 @@ import { t } from "$lib/i18n.svelte"; import type { ActionData, PageData } from "./$types"; const { data, form } = $props<{ data: PageData; form?: ActionData }>(); +const result = $derived(form as { sent?: boolean; error?: string } | null); {#if data.skinHtml} @@ -16,12 +17,53 @@ const { data, form } = $props<{ data: PageData; form?: ActionData }>(); {@html data.skinHtml} {:else}
-
-

{t("find_password.title")}

-

{t("find_password.not_available")}

- - {t("login.submit")} - +
+
+

{t("find_password.title")}

+

{t("find_password.subtitle")}

+
+ + {#if result?.error} +
{result.error}
+ {/if} + + {#if result?.sent} +
+ {t("find_password.result_sent")} +
+ {:else} +
+
+ + +
+
+ + +
+ +
+ {/if} + +
{/if} diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts index 6fca643..20fc9ff 100644 --- a/src/routes/(auth)/login/+page.server.ts +++ b/src/routes/(auth)/login/+page.server.ts @@ -66,6 +66,8 @@ export const load: PageServerLoad = async ({ locals, url, platform }) => { skinHtml, dbReady: Boolean(locals.db), runtimeError: locals.runtimeError, + registered: url.searchParams.get("registered") === "1", + passwordReset: url.searchParams.get("passwordReset") === "1", }; }; diff --git a/src/routes/(auth)/login/+page.svelte b/src/routes/(auth)/login/+page.svelte index bb0132a..54cd9d8 100644 --- a/src/routes/(auth)/login/+page.svelte +++ b/src/routes/(auth)/login/+page.svelte @@ -1,5 +1,6 @@ + +
+
+
+

{t("reset_password.title")}

+

{t("reset_password.subtitle")}

+
+ + {#if !data.valid} +
+ {t("reset_password.invalid_link")} +
+ + {t("find_password.title")} → + + {:else} + {#if err} +
{err}
+ {/if} +
+ +
+ + +
+
+ + +
+ +
+ {/if} +
+
diff --git a/src/routes/(auth)/signup/+page.server.ts b/src/routes/(auth)/signup/+page.server.ts index ae7f753..3747022 100644 --- a/src/routes/(auth)/signup/+page.server.ts +++ b/src/routes/(auth)/signup/+page.server.ts @@ -1,6 +1,11 @@ -import { fail } from "@sveltejs/kit"; +import { fail, redirect } from "@sveltejs/kit"; +import { eq, and } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; import { resolveSkinHtml, replacePlaceholders, escapeHtml } from "$lib/server/skin/resolver"; +import { requireDbContext } from "$lib/server/auth/guards"; +import { hashPassword } from "$lib/server/auth/password"; +import { users, credentials, identities } from "$lib/server/db/schema"; +import { resolve } from "$app/paths"; export const load: PageServerLoad = async ({ locals, url, platform }) => { const skinHint = url.searchParams.get("skinHint"); @@ -27,7 +32,47 @@ export const load: PageServerLoad = async ({ locals, url, platform }) => { }; export const actions: Actions = { - default: async () => { - return fail(501, { error: "회원가입 기능은 아직 구현되지 않았습니다." }); + default: async (event) => { + const { db, tenant } = requireDbContext(event.locals); + + const formData = await event.request.formData(); + const username = String(formData.get("username") ?? "") + .trim() + .toLowerCase(); + const email = String(formData.get("email") ?? "") + .trim() + .toLowerCase(); + const password = String(formData.get("password") ?? ""); + const confirmPassword = String(formData.get("confirmPassword") ?? ""); + + if (!username || !email || !password) return fail(400, { error: "모든 필드를 입력해 주세요." }); + if (!/^[a-z0-9_]{3,32}$/.test(username)) return fail(400, { error: "아이디는 영문 소문자, 숫자, _만 사용 가능하며 3~32자여야 합니다." }); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return fail(400, { error: "올바른 이메일 주소를 입력해 주세요." }); + if (password.length < 8) return fail(400, { error: "비밀번호는 8자 이상이어야 합니다." }); + if (password !== confirmPassword) return fail(400, { error: "비밀번호가 일치하지 않습니다." }); + + const [existingByUsername] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.tenantId, tenant.id), eq(users.username, username))) + .limit(1); + if (existingByUsername) return fail(409, { error: "이미 사용 중인 아이디입니다." }); + + const [existingByEmail] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.tenantId, tenant.id), eq(users.email, email))) + .limit(1); + if (existingByEmail) return fail(409, { error: "이미 사용 중인 이메일입니다." }); + + const hashedPw = await hashPassword(password); + const userId = crypto.randomUUID(); + const now = new Date(); + + await db.insert(users).values({ id: userId, tenantId: tenant.id, username, email, displayName: username, role: "user", status: "active" }); + await db.insert(credentials).values({ userId, type: "password", secret: hashedPw, label: "비밀번호", createdAt: now }); + await db.insert(identities).values({ tenantId: tenant.id, userId, provider: "local", subject: email, email, linkedAt: now }); + + throw redirect(302, resolve("/login") + "?registered=1"); }, }; diff --git a/src/routes/(auth)/signup/+page.svelte b/src/routes/(auth)/signup/+page.svelte index ef56744..3c1f00d 100644 --- a/src/routes/(auth)/signup/+page.svelte +++ b/src/routes/(auth)/signup/+page.svelte @@ -16,12 +16,68 @@ const { data, form } = $props<{ data: PageData; form?: ActionData }>(); {@html data.skinHtml} {:else}
-
-

{t("signup.title")}

-

{t("signup.not_available")}

- - {t("login.submit")} - +
+
+

{t("signup.title")}

+

{t("signup.subtitle")}

+
+ + {#if form?.error} +
{form.error}
+ {/if} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +

+ {t("signup.have_account")} + {t("login.submit")} +

{/if} From 8a0f67d08da1d8798ff0592f8d3b9d82d3bfba89 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Mon, 20 Apr 2026 17:48:51 +0900 Subject: [PATCH 13/18] =?UTF-8?q?fix:=20nodemailer=20transporter=20MaxList?= =?UTF-8?q?eners=20=EA=B2=BD=EA=B3=A0=20=EC=96=B5=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/email.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts index cd2188c..5d87bdf 100644 --- a/src/lib/server/email.ts +++ b/src/lib/server/email.ts @@ -28,6 +28,7 @@ async function send(to: string, subject: string, html: string): Promise { secure: smtp.secure, auth: { user: smtp.username, pass: smtp.password }, }); + transporter.setMaxListeners(20); try { await transporter.sendMail({ from: smtp.senderAddress, to, subject, html }); From 06a0efbf5b4155bf97179117775d4daca29b408d Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Mon, 20 Apr 2026 17:49:56 +0900 Subject: [PATCH 14/18] =?UTF-8?q?fix:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20URL=20issuer=20?= =?UTF-8?q?=EB=81=9D=20=EC=A0=90/=EC=8A=AC=EB=9E=98=EC=8B=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/routes/(auth)/find-password/+page.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/(auth)/find-password/+page.server.ts b/src/routes/(auth)/find-password/+page.server.ts index 8c4094a..044e05d 100644 --- a/src/routes/(auth)/find-password/+page.server.ts +++ b/src/routes/(auth)/find-password/+page.server.ts @@ -59,7 +59,7 @@ export const actions: Actions = { const { token, tokenHash } = await generateToken(); const expiresAt = new Date(Date.now() + RESET_EXPIRY_MS); await db.insert(passwordResetTokens).values({ userId: user.id, tokenHash, expiresAt }); - const issuer = env.IDP_ISSUER_URL ?? event.url.origin; + const issuer = (env.IDP_ISSUER_URL ?? event.url.origin).replace(/\.+$/, "").replace(/\/+$/, ""); const resetUrl = `${issuer}${resolve("/reset-password")}?token=${token}`; await sendPasswordResetEmail(user.email, resetUrl); } catch { From 5d2e960abda6032ecfb84edae5e2dc0d256fb717 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Mon, 20 Apr 2026 18:05:23 +0900 Subject: [PATCH 15/18] =?UTF-8?q?fix:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20URL=EC=97=90=EC=84=9C?= =?UTF-8?q?=20resolve()=20=EC=A0=9C=EA=B1=B0=20(=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=20./path=20=EB=AC=B8=EC=A0=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/routes/(auth)/find-password/+page.server.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/routes/(auth)/find-password/+page.server.ts b/src/routes/(auth)/find-password/+page.server.ts index 044e05d..d37764b 100644 --- a/src/routes/(auth)/find-password/+page.server.ts +++ b/src/routes/(auth)/find-password/+page.server.ts @@ -6,7 +6,6 @@ import { requireDbContext } from "$lib/server/auth/guards"; import { users, passwordResetTokens } from "$lib/server/db/schema"; import { sendPasswordResetEmail, generateToken } from "$lib/server/email"; import { env } from "$env/dynamic/private"; -import { resolve } from "$app/paths"; const RESET_EXPIRY_MS = 60 * 60 * 1000; @@ -60,7 +59,7 @@ export const actions: Actions = { const expiresAt = new Date(Date.now() + RESET_EXPIRY_MS); await db.insert(passwordResetTokens).values({ userId: user.id, tokenHash, expiresAt }); const issuer = (env.IDP_ISSUER_URL ?? event.url.origin).replace(/\.+$/, "").replace(/\/+$/, ""); - const resetUrl = `${issuer}${resolve("/reset-password")}?token=${token}`; + const resetUrl = `${issuer}/reset-password?token=${token}`; await sendPasswordResetEmail(user.email, resetUrl); } catch { // 조용히 무시 From 3943833acdbad8131f0489efc46f066ceb36e7ed Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Tue, 21 Apr 2026 09:24:38 +0900 Subject: [PATCH 16/18] =?UTF-8?q?feat:=20OIDC=20FC/BC=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EB=B0=8F=20SAML=20SLO=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - oidcClients 스키마에 FC/BC 로그아웃 URI 컬럼 추가 (마이그레이션 0010) - OIDC 백채널 로그아웃: logout_token JWT 생성 후 waitUntil로 비동기 POST - OIDC 프론트채널 로그아웃: end-session 시 iframe 렌더링 후 자동 리다이렉트 - SAML SP-initiated SLO: LogoutRequest 파싱, 서명 검증, LogoutResponse 반환 - IDP-initiated 로그아웃 시 OIDC BC 및 SAML SLO 트리거 - OpenID 메타데이터에 FC/BC 로그아웃 지원 여부 추가 - 관리자 UI에 FC/BC 로그아웃 URI 설정 필드 추가 Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/db/schema.ts | 4 + src/lib/server/oidc/logout.ts | 132 +++++++++ src/lib/server/saml/slo.ts | 275 ++++++++++++++++++ src/routes/(auth)/logout/+page.server.ts | 60 +++- .../openid-configuration/+server.ts | 4 + src/routes/admin/oidc-clients/+page.server.ts | 20 ++ src/routes/admin/oidc-clients/+page.svelte | 64 ++++ src/routes/oidc/end-session/+server.ts | 126 +++++--- src/routes/saml/slo/+server.ts | 213 +++++++++++++- svelte.config.js | 2 +- 10 files changed, 840 insertions(+), 60 deletions(-) create mode 100644 src/lib/server/oidc/logout.ts create mode 100644 src/lib/server/saml/slo.ts diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 55f3935..6aaf555 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -208,6 +208,10 @@ export const oidcClients = sqliteTable( name: text("name").notNull(), redirectUris: text("redirect_uris").notNull(), postLogoutRedirectUris: text("post_logout_redirect_uris"), + frontchannelLogoutUri: text("frontchannel_logout_uri"), + frontchannelLogoutSessionRequired: integer("frontchannel_logout_session_required", { mode: "boolean" }).notNull().default(false), + backchannelLogoutUri: text("backchannel_logout_uri"), + backchannelLogoutSessionRequired: integer("backchannel_logout_session_required", { mode: "boolean" }).notNull().default(false), scopes: text("scopes").notNull().default("openid"), grantTypes: text("grant_types").notNull().default("authorization_code,refresh_token"), responseTypes: text("response_types").notNull().default("code"), diff --git a/src/lib/server/oidc/logout.ts b/src/lib/server/oidc/logout.ts new file mode 100644 index 0000000..ef83aa1 --- /dev/null +++ b/src/lib/server/oidc/logout.ts @@ -0,0 +1,132 @@ +/** + * OIDC Single Logout helpers (Front-channel 1.0 + Back-channel 1.0). + * + * Back-channel (BC): + * - IdP POSTs signed logout_token (JWT) to clients' `backchannel_logout_uri`. + * - Target set = clients with an active grant or refresh-token bound to this IdP session. + * + * Front-channel (FC): + * - IdP renders `).join(""); + // CSP 는 hash 모드이므로 inline JS 를 피하고 meta refresh 를 사용한다. + return ( + `` + + `Logging out...` + + `` + + `` + + `

로그아웃 중...

` + + iframes + + `` + ); +} + +async function resolvePostLogoutRedirect(locals: App.Locals, postLogoutRedirectUri: string | null, clientId: string | null): Promise { + if (!postLogoutRedirectUri || !clientId || !locals.db || !locals.tenant) return "/"; + const [client] = await locals.db + .select({ postLogoutRedirectUris: oidcClients.postLogoutRedirectUris }) + .from(oidcClients) + .where(and(eq(oidcClients.tenantId, locals.tenant.id), eq(oidcClients.clientId, clientId), eq(oidcClients.enabled, true))) + .limit(1); + if (!client?.postLogoutRedirectUris) return "/"; + let allowed: string[]; + try { + allowed = JSON.parse(client.postLogoutRedirectUris) as string[]; + } catch { + allowed = []; + } + if (Array.isArray(allowed) && allowed.includes(postLogoutRedirectUri)) return postLogoutRedirectUri; + return "/"; +} + +export const GET: RequestHandler = async (event) => { + const { locals, url, cookies, platform } = event; const postLogoutRedirectUri = url.searchParams.get("post_logout_redirect_uri"); const clientId = url.searchParams.get("client_id"); const idTokenHint = url.searchParams.get("id_token_hint"); - // id_token_hint 제공 시 서명 검증 및 sub 일치 확인 if (idTokenHint && locals.db && locals.tenant) { const claims = await verifyIdToken(locals.db, locals.tenant.id, idTokenHint); if (!claims) { return new Response(JSON.stringify({ error: "invalid_id_token_hint" }), { status: 400, headers: { "Content-Type": "application/json" }, - }) as unknown as never; + }); } - // 현재 세션 사용자와 불일치 시 거부 if (locals.user && claims.sub !== locals.user.id) { return new Response(JSON.stringify({ error: "id_token_hint_mismatch" }), { status: 400, headers: { "Content-Type": "application/json" }, - }) as unknown as never; + }); } } - // IdP 세션 폐기 - if (locals.session && locals.db) { - await revokeSession(locals.db, locals.session.idpSessionId); - clearSessionCookie(cookies, url); - } + // 세션이 있으면 BC/FC 로그아웃 타깃을 수집·발송한 뒤 세션을 폐기한다. + // (세션 폐기 전에 sessionId / idpSessionId / userId 를 캡처해야 함.) + if (locals.session && locals.user && locals.db && locals.tenant) { + const db = locals.db; + const tenantId = locals.tenant.id; + const sessionId = locals.session.id; + const idpSessionId = locals.session.idpSessionId; + const userId = locals.user.id; - // post_logout_redirect_uri 검증 후 리다이렉트 - if (postLogoutRedirectUri && clientId && locals.db && locals.tenant) { - const [client] = await locals.db - .select({ postLogoutRedirectUris: oidcClients.postLogoutRedirectUris }) - .from(oidcClients) - .where(and(eq(oidcClients.tenantId, locals.tenant.id), eq(oidcClients.clientId, clientId), eq(oidcClients.enabled, true))) - .limit(1); - - if (client?.postLogoutRedirectUris) { - let allowed: string[]; - try { - allowed = JSON.parse(client.postLogoutRedirectUris) as string[]; - } catch { - allowed = []; - } - if (Array.isArray(allowed) && allowed.includes(postLogoutRedirectUri)) { - throw redirect(302, postLogoutRedirectUri); + const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; + const signingKeySecret = locals.runtimeConfig.signingKeySecret; + + const bcTargets = await getOidcBackchannelTargets(db, tenantId, sessionId); + const fcTargets = await getOidcFrontchannelTargets(db, tenantId, sessionId, idpSessionId, issuerUrl); + + // BC 로그아웃 발송 (서명 키가 있을 때만) + if (bcTargets.length > 0 && signingKeySecret) { + const signingKey = await getActiveSigningKey(db, tenantId, signingKeySecret); + if (signingKey) { + const bcPromises = bcTargets.map((t) => sendOneBackchannelLogout(t, userId, idpSessionId, issuerUrl, signingKey.privateKey, signingKey.kid).catch(() => undefined)); + const wait = platform?.ctx?.waitUntil?.bind(platform.ctx); + if (wait) { + wait(Promise.all(bcPromises)); + } else { + await Promise.all(bcPromises); + } } } - } - throw redirect(302, "/"); -} + // IdP 세션 폐기 + await revokeSession(db, idpSessionId); + clearSessionCookie(cookies, url); + + // FC 타깃이 있으면 iframe 페이지를 렌더 + if (fcTargets.length > 0) { + const redirectTo = await resolvePostLogoutRedirect(locals, postLogoutRedirectUri, clientId); + const html = renderFrontchannelLogoutHtml( + fcTargets.map((t) => t.uri), + redirectTo, + ); + return new Response(html, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + + // FC 가 없으면 바로 리다이렉트 + const redirectTo = await resolvePostLogoutRedirect(locals, postLogoutRedirectUri, clientId); + throw redirect(302, redirectTo); + } -export const GET: RequestHandler = ({ locals, url, cookies }) => handleEndSession(locals, url, cookies); + // 세션이 없는 경우: 기존 흐름 + const redirectTo = await resolvePostLogoutRedirect(locals, postLogoutRedirectUri, clientId); + throw redirect(302, redirectTo); +}; -export const POST: RequestHandler = ({ locals, url, cookies }) => handleEndSession(locals, url, cookies); +export const POST: RequestHandler = (event) => GET(event); diff --git a/src/routes/saml/slo/+server.ts b/src/routes/saml/slo/+server.ts index 66d1e84..581cf93 100644 --- a/src/routes/saml/slo/+server.ts +++ b/src/routes/saml/slo/+server.ts @@ -1,26 +1,171 @@ /** - * SAML 2.0 Single Logout (SLO) 엔드포인트 — M2 최소 구현. + * SAML 2.0 Single Logout (SLO) 엔드포인트 — SP-initiated. * * GET /saml/slo - * - 현재 IdP 세션을 무효화하고 RelayState 또는 홈으로 리다이렉트. - * - LogoutRequest 파싱·서명 검증은 M3 에서 구현 예정. + * - SAMLRequest(LogoutRequest) 파싱 → SessionIndex 로 해당 SAML 세션 식별 + * - SP 인증서가 등록돼 있으면 HTTP-Redirect 서명 검증 + * - 해당 SAML 세션 종료 (endedAt) + * - 같은 IdP 세션에 묶인 다른 SP 를 대상으로 best-effort 로그아웃 + * (iframe 기반 FC, JS-free; HTTP-Redirect 바인딩 SP 만) + * - OIDC back-channel 로그아웃은 waitUntil 로 발송 + * - IdP 세션 폐기, LogoutResponse 로 원 SP 에 302 + * + * SAMLRequest 가 없으면 단순히 세션만 폐기하고 "/" 로 리다이렉트. */ -import { redirect } from "@sveltejs/kit"; +import { error, redirect } from "@sveltejs/kit"; import type { RequestHandler } from "./$types"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; +import { samlSessions, samlSps, sessions } from "$lib/server/db/schema"; +import { getRequestMetadata, recordAuditEvent } from "$lib/server/audit"; import { requireDbContext } from "$lib/server/auth/guards"; -import { sessions } from "$lib/server/db/schema"; -import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit"; import { SESSION_COOKIE_NAME } from "$lib/server/auth/constants"; +import { getActiveSigningKey } from "$lib/server/crypto/keys"; +import { getOidcBackchannelTargets, sendOneBackchannelLogout } from "$lib/server/oidc/logout"; +import { buildSamlLogoutRequest, buildSamlLogoutResponse, buildSamlSloRedirectUrl, getActiveSamlSessionsForSession, parseSamlLogoutRequest } from "$lib/server/saml/slo"; +import { verifySamlRedirectSignature } from "$lib/server/saml/parse-authn-request"; + +function htmlEscape(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} export const GET: RequestHandler = async (event) => { - const { locals, cookies } = event; + const { locals, url, cookies, platform } = event; const { db, tenant } = requireDbContext(locals); - if (locals.session) { - await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, locals.session.id)); + const samlRequest = url.searchParams.get("SAMLRequest"); + const relayState = url.searchParams.get("RelayState"); + + // SAMLRequest 없으면 세션만 폐기하고 홈으로 (IdP 콘솔 테스트 등) + if (!samlRequest) { + if (locals.session) { + await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, locals.session.id)); + cookies.delete(SESSION_COOKIE_NAME, { path: "/" }); + } + throw redirect(302, "/"); + } + + // 1. LogoutRequest 파싱 + let parsed; + try { + parsed = await parseSamlLogoutRequest(samlRequest); + } catch { + throw error(400, "Invalid SAMLRequest"); + } + + // 2. SP 조회 (issuer = entityId) + const [sp] = await db + .select() + .from(samlSps) + .where(and(eq(samlSps.tenantId, tenant.id), eq(samlSps.entityId, parsed.issuer), eq(samlSps.enabled, true))) + .limit(1); + if (!sp) { + throw error(400, "Unknown SAML SP"); + } + + // 3. 서명 검증 (SP cert 가 있으면 필수) + if (sp.cert) { + const rawQuery = url.search.replace(/^\?/, ""); + const valid = await verifySamlRedirectSignature(rawQuery, sp.cert); + if (!valid) { + throw error(400, "Invalid SAMLRequest signature"); + } + } + + // 4. SessionIndex → SAML 세션 및 IdP 세션 식별 + // (동일 SP 가 여러 SessionIndex 를 보낼 수 있으나 M3 기준 하나만 처리) + const targetSessionIndex = parsed.sessionIndexes[0]; + let idpSession: typeof sessions.$inferSelect | null = null; + let linkedSamlSessionId: string | null = null; + if (targetSessionIndex) { + const [row] = await db + .select({ samlSessionId: samlSessions.id, idpSessionId: samlSessions.sessionId }) + .from(samlSessions) + .where(and(eq(samlSessions.tenantId, tenant.id), eq(samlSessions.spId, sp.id), eq(samlSessions.sessionIndex, targetSessionIndex))) + .limit(1); + if (row) { + linkedSamlSessionId = row.samlSessionId; + if (row.idpSessionId) { + const [s] = await db.select().from(sessions).where(eq(sessions.id, row.idpSessionId)).limit(1); + idpSession = s ?? null; + } + } + } + // fallback — 현재 브라우저 세션 + if (!idpSession && locals.session) idpSession = locals.session; + + // 5. SAML 세션 종료 + if (linkedSamlSessionId) { + await db.update(samlSessions).set({ endedAt: new Date() }).where(eq(samlSessions.id, linkedSamlSessionId)); + } + + // 6. 다른 활성 SAML 세션 수집 (best-effort FC 로그아웃용) + let otherSamlIframeUris: string[] = []; + if (idpSession) { + const siblings = await getActiveSamlSessionsForSession(db, idpSession.id); + const remaining = siblings.filter((s) => s.id !== linkedSamlSessionId); + const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; + const signingKeySecret = locals.runtimeConfig.signingKeySecret; + const signingKey = signingKeySecret ? await getActiveSigningKey(db, tenant.id, signingKeySecret) : null; + + if (signingKey) { + const urls: string[] = []; + for (const s of remaining) { + if (!s.sp.sloUrl) continue; + const lrXml = buildSamlLogoutRequest({ + id: `_l${crypto.randomUUID().replace(/-/g, "")}`, + issuerUrl, + destination: s.sp.sloUrl, + nameId: s.nameId, + nameIdFormat: s.nameIdFormat ?? "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + sessionIndex: s.sessionIndex, + }); + try { + const signed = await buildSamlSloRedirectUrl({ + sloUrl: s.sp.sloUrl, + xml: lrXml, + param: "SAMLRequest", + privateKey: signingKey.privateKey, + }); + urls.push(signed); + } catch { + // skip + } + } + otherSamlIframeUris = urls; + } + + // IdP 관점에서 이 세션에 묶인 모든 SAML 세션을 종료로 표시한다. + // (서명 키가 없어 FC 알림을 못 보내더라도 IdP DB 는 일관성을 유지해야 함.) + await db.update(samlSessions).set({ endedAt: new Date() }).where(eq(samlSessions.sessionId, idpSession.id)); + } + // 7. OIDC BC 로그아웃 (waitUntil) + if (idpSession && locals.runtimeConfig.signingKeySecret) { + const bcTargets = await getOidcBackchannelTargets(db, tenant.id, idpSession.id); + if (bcTargets.length > 0) { + const signingKey = await getActiveSigningKey(db, tenant.id, locals.runtimeConfig.signingKeySecret); + if (signingKey) { + const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; + const bcPromises = bcTargets.map((t) => + sendOneBackchannelLogout(t, idpSession.userId, idpSession.idpSessionId, issuerUrl, signingKey.privateKey, signingKey.kid).catch(() => undefined), + ); + const wait = platform?.ctx?.waitUntil?.bind(platform.ctx); + if (wait) { + wait(Promise.all(bcPromises)); + } else { + await Promise.all(bcPromises); + } + } + } + } + + // 8. IdP 세션 폐기 + if (idpSession) { + await db + .update(sessions) + .set({ revokedAt: new Date() }) + .where(and(eq(sessions.id, idpSession.id), eq(sessions.tenantId, tenant.id))); if (locals.user) { const requestMetadata = getRequestMetadata(event); await recordAuditEvent(db, { @@ -31,12 +176,54 @@ export const GET: RequestHandler = async (event) => { outcome: "success", ip: requestMetadata.ip, userAgent: requestMetadata.userAgent, - detail: {}, + detail: { spEntityId: parsed.issuer }, }); } - cookies.delete(SESSION_COOKIE_NAME, { path: "/" }); } - throw redirect(302, "/"); + // 9. LogoutResponse 생성 → SP 로 리다이렉트 + if (!sp.sloUrl) { + throw redirect(302, "/"); + } + const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; + const responseXml = buildSamlLogoutResponse({ + id: `_lr${crypto.randomUUID().replace(/-/g, "")}`, + inResponseTo: parsed.id, + issuerUrl, + destination: sp.sloUrl, + status: "Success", + }); + + const signingKeySecret = locals.runtimeConfig.signingKeySecret; + if (!signingKeySecret) { + // 서명키 없으면 부득이하게 미서명 응답은 불가 → 세션만 폐기하고 홈으로 + throw redirect(302, "/"); + } + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + if (!signingKey) { + throw redirect(302, "/"); + } + const redirectUrl = await buildSamlSloRedirectUrl({ + sloUrl: sp.sloUrl, + xml: responseXml, + param: "SAMLResponse", + relayState, + privateKey: signingKey.privateKey, + }); + + // 다른 SP 들이 남아 있으면 iframe 으로 선 로그아웃 시도 후 3초 뒤 리다이렉트 + if (otherSamlIframeUris.length > 0) { + const iframes = otherSamlIframeUris.map((u) => ``).join(""); + const html = + `` + + `Logging out...` + + `` + + `

로그아웃 중...

${iframes}`; + return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } }); + } + + throw redirect(302, redirectUrl); }; + +export const POST: RequestHandler = (event) => GET(event); diff --git a/svelte.config.js b/svelte.config.js index 4481cf9..66e3fc8 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -18,7 +18,7 @@ const config = { "font-src": ["self", "data:"], "connect-src": ["self"], "frame-ancestors": ["none"], - "form-action": ["self", "https:"], // SAML ACS HTTP-POST 바인딩 + "form-action": ["self"], // SAML ACS auto-submit은 raw Response라 SvelteKit CSP 비적용 "base-uri": ["self"], "object-src": ["none"], }, From 10ceb0cb1681a255e48a8e854218683c6c558921 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Tue, 21 Apr 2026 09:33:39 +0900 Subject: [PATCH 17/18] =?UTF-8?q?feat:=20SAML=20SLO=20sequential=20redirec?= =?UTF-8?q?t=20chain=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - samlSloStates 테이블 추가로 체인 상태 DB 추적 (마이그레이션 0011) - SP-initiated SLO: 다른 SP들을 순서대로 방문 후 최종 LogoutResponse 반환 - IDP-initiated SLO: /logout에서 체인 시작, 모든 SP 순회 후 세션 폐기 - SAMLResponse+RelayState로 체인 이어가기 (팝-퍼스트로 중복 처리 방지) - 체인 완료 시 OIDC BC 로그아웃 + IDP 세션 폐기 + 쿠키 삭제 Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/db/schema.ts | 33 ++ src/lib/server/saml/slo.ts | 51 ++- src/routes/(auth)/logout/+page.server.ts | 105 +++-- src/routes/saml/slo/+server.ts | 487 +++++++++++++++-------- 4 files changed, 478 insertions(+), 198 deletions(-) diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 6aaf555..1cde5ef 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -357,6 +357,38 @@ export const samlSessions = sqliteTable( (t) => [uniqueIndex("saml_sessions_session_index_uidx").on(t.sessionIndex), index("saml_sessions_tenant_sp_idx").on(t.tenantId, t.spId)], ); +/** + * SAML SLO 체인 상태. 여러 SP 를 순차적으로 로그아웃하기 위한 리다이렉트 체인을 + * DB 에 저장해 둔다. id 값이 RelayState 로 전달되어 체인 전반에 걸쳐 식별자 역할을 한다. + */ +export const samlSloStates = sqliteTable("saml_slo_states", { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + tenantId: text("tenant_id") + .notNull() + .references(() => tenants.id, { onDelete: "cascade" }), + // sessions.id — FK 로 걸지 않는다 (체인 중간에 세션이 revoke 될 수 있음) + idpSessionRecordId: text("idp_session_record_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // SP-initiated SLO 일 때만 값이 있다. + initiatingSpEntityId: text("initiating_sp_entity_id"), + // 최초 SP 가 보낸 LogoutRequest ID (InResponseTo 에 사용) + inResponseTo: text("in_response_to"), + // 체인 종료 시 LogoutResponse 를 보낼 SP 의 SLO URL (SP-initiated) + initiatorSloUrl: text("initiator_slo_url"), + // 체인 종료 시 최종적으로 리다이렉트할 URI (예: "/login") + completionUri: text("completion_uri").notNull(), + // JSON array: [{spId, entityId, sloUrl, nameId, nameIdFormat, sessionIndex}] + pendingSpDataJson: text("pending_sp_data_json").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .notNull() + .default(sql`(unixepoch() * 1000)`), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), +}); + // ---------- Keys & Audit ---------- /** @@ -724,6 +756,7 @@ export type OidcGrant = typeof oidcGrants.$inferSelect; export type OidcRefreshToken = typeof oidcRefreshTokens.$inferSelect; export type SamlSp = typeof samlSps.$inferSelect; export type SamlSession = typeof samlSessions.$inferSelect; +export type SamlSloState = typeof samlSloStates.$inferSelect; export type SigningKey = typeof signingKeys.$inferSelect; export type AuditEvent = typeof auditEvents.$inferSelect; export type Position = typeof positions.$inferSelect; diff --git a/src/lib/server/saml/slo.ts b/src/lib/server/saml/slo.ts index 37ca018..0664881 100644 --- a/src/lib/server/saml/slo.ts +++ b/src/lib/server/saml/slo.ts @@ -9,7 +9,7 @@ import "reflect-metadata"; import { DOMParser } from "@xmldom/xmldom"; -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, isNull, ne } from "drizzle-orm"; import type { DB } from "$lib/server/db"; import { samlSessions, samlSps } from "$lib/server/db/schema"; @@ -273,3 +273,52 @@ export async function getActiveSamlSessionsForSession(db: DB, sessionId: string) }, })); } + +/** + * SLO 체인 진행 중 samlSloStates.pendingSpDataJson 에 직렬화되는 SP 단위 데이터. + */ +export interface PendingSpData { + spId: string; + entityId: string; + sloUrl: string; + nameId: string; + nameIdFormat: string; + sessionIndex: string; +} + +/** + * 주어진 IdP 세션에 묶여 있으면서 HTTP-Redirect SLO 가 가능한 (sloUrl 이 있는) 활성 SAML 세션을 + * PendingSpData[] 로 수집한다. excludeSpEntityId 가 주어지면 해당 SP 는 제외한다 (예: SP-initiated + * SLO 에서 최초 요청자 SP 를 체인에서 빼고 싶을 때). + */ +export async function collectPendingSpData(db: DB, sessionId: string, excludeSpEntityId?: string): Promise { + const baseCond = and(eq(samlSessions.sessionId, sessionId), isNull(samlSessions.endedAt)); + const whereCond = excludeSpEntityId ? and(baseCond, ne(samlSps.entityId, excludeSpEntityId)) : baseCond; + + const rows = await db + .select({ + spId: samlSessions.spId, + sessionIndex: samlSessions.sessionIndex, + nameId: samlSessions.nameId, + nameIdFormat: samlSessions.nameIdFormat, + entityId: samlSps.entityId, + sloUrl: samlSps.sloUrl, + }) + .from(samlSessions) + .innerJoin(samlSps, eq(samlSessions.spId, samlSps.id)) + .where(whereCond); + + const result: PendingSpData[] = []; + for (const r of rows) { + if (!r.sloUrl) continue; + result.push({ + spId: r.spId, + entityId: r.entityId, + sloUrl: r.sloUrl, + nameId: r.nameId, + nameIdFormat: r.nameIdFormat ?? "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + sessionIndex: r.sessionIndex, + }); + } + return result; +} diff --git a/src/routes/(auth)/logout/+page.server.ts b/src/routes/(auth)/logout/+page.server.ts index ea9b07f..26adbde 100644 --- a/src/routes/(auth)/logout/+page.server.ts +++ b/src/routes/(auth)/logout/+page.server.ts @@ -7,49 +7,74 @@ import { clearSessionCookie, revokeSession } from "$lib/server/auth/session"; import { SESSION_COOKIE_NAME } from "$lib/server/auth/constants"; import { getActiveSigningKey } from "$lib/server/crypto/keys"; import { getOidcBackchannelTargets, sendOneBackchannelLogout } from "$lib/server/oidc/logout"; +import { samlSloStates } from "$lib/server/db/schema"; +import { collectPendingSpData } from "$lib/server/saml/slo"; + +const SLO_STATE_TTL_MS = 10 * 60 * 1000; // 10 분 /** * IdP-initiated 로그아웃. * - * 브라우저 기반 FC / SAML HTTP-Redirect SLO 는 form 제출→redirect 흐름에서 - * iframe 렌더가 불가능하므로 이 라우트에서는 제공하지 않는다. - * (그런 플로우는 /oidc/end-session 이나 /saml/slo 로부터 진입해야 한다.) + * 활성 SAML 세션이 하나라도 있으면 순차 리다이렉트 체인(/saml/slo) 으로 위임하고, + * 그렇지 않으면 곧바로 세션을 폐기한다. + * + * - SAML 체인에 넘기는 경우: IdP 세션과 쿠키는 체인 종료 시점(/saml/slo) 에서 정리된다. + * - OIDC back-channel 로그아웃은 양쪽 경로에서 waitUntil 로 발사된다. * - * 단, OIDC back-channel 과 SAML IdP-initiated 서버 측 발송은 best-effort 로 - * waitUntil 을 통해 발사한 뒤 /login 으로 리다이렉트한다. + * 반환값: SAML 체인을 타야 하면 samlSloStates.id, 아니면 null. */ -async function performLogout(event: RequestEvent) { +async function performLogout(event: RequestEvent): Promise { const sessionToken = event.cookies.get(SESSION_COOKIE_NAME); - if (sessionToken && event.locals.db && event.locals.tenant && event.locals.session && event.locals.user) { - const { db, tenant } = requireDbContext(event.locals); - const requestMetadata = getRequestMetadata(event); + if (!sessionToken || !event.locals.db || !event.locals.tenant || !event.locals.session || !event.locals.user) { + clearSessionCookie(event.cookies, event.url); + return null; + } + + const { db, tenant } = requireDbContext(event.locals); + const requestMetadata = getRequestMetadata(event); - // 세션 값 캡처 (폐기 전) - const sessionId = event.locals.session.id; - const idpSessionId = event.locals.session.idpSessionId; - const userId = event.locals.user.id; + // 세션 값 캡처 (폐기 전) + const sessionId = event.locals.session.id; + const idpSessionId = event.locals.session.idpSessionId; + const userId = event.locals.user.id; - // OIDC back-channel 로그아웃 발송 (waitUntil) - const issuerUrl = event.locals.runtimeConfig.issuerUrl ?? event.url.origin; - const signingKeySecret = event.locals.runtimeConfig.signingKeySecret; - if (signingKeySecret) { - const bcTargets = await getOidcBackchannelTargets(db, tenant.id, sessionId); - if (bcTargets.length > 0) { - const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); - if (signingKey) { - const bcPromises = bcTargets.map((t) => sendOneBackchannelLogout(t, userId, idpSessionId, issuerUrl, signingKey.privateKey, signingKey.kid).catch(() => undefined)); - const wait = event.platform?.ctx?.waitUntil?.bind(event.platform.ctx); - if (wait) { - wait(Promise.all(bcPromises)); - } else { - await Promise.all(bcPromises); - } + // OIDC back-channel 로그아웃 발송 (waitUntil) + const issuerUrl = event.locals.runtimeConfig.issuerUrl ?? event.url.origin; + const signingKeySecret = event.locals.runtimeConfig.signingKeySecret; + if (signingKeySecret) { + const bcTargets = await getOidcBackchannelTargets(db, tenant.id, sessionId); + if (bcTargets.length > 0) { + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + if (signingKey) { + const bcPromises = bcTargets.map((t) => sendOneBackchannelLogout(t, userId, idpSessionId, issuerUrl, signingKey.privateKey, signingKey.kid).catch(() => undefined)); + const wait = event.platform?.ctx?.waitUntil?.bind(event.platform.ctx); + if (wait) { + wait(Promise.all(bcPromises)); + } else { + await Promise.all(bcPromises); } } } + } - await revokeSession(db, idpSessionId); + // SAML 세션이 활성인 경우 → SLO 체인 개시를 위해 state 를 만들어 두고, + // 실제 세션/쿠키 폐기는 체인 종료 시점으로 미룬다. + const samlPending = await collectPendingSpData(db, sessionId); + if (samlPending.length > 0) { + const stateId = crypto.randomUUID(); + await db.insert(samlSloStates).values({ + id: stateId, + tenantId: tenant.id, + idpSessionRecordId: sessionId, + userId, + initiatingSpEntityId: null, + inResponseTo: null, + initiatorSloUrl: null, + completionUri: "/login", + pendingSpDataJson: JSON.stringify(samlPending), + expiresAt: new Date(Date.now() + SLO_STATE_TTL_MS), + }); await recordAuditEvent(db, { tenantId: tenant.id, @@ -59,10 +84,27 @@ async function performLogout(event: RequestEvent) { outcome: "success", ip: requestMetadata.ip, userAgent: requestMetadata.userAgent, + detail: { chained: true, pending: samlPending.length }, }); + + return stateId; } + // SAML 세션이 없으면 즉시 로그아웃 완료 + await revokeSession(db, idpSessionId); + + await recordAuditEvent(db, { + tenantId: tenant.id, + userId, + actorId: userId, + kind: "logout", + outcome: "success", + ip: requestMetadata.ip, + userAgent: requestMetadata.userAgent, + }); + clearSessionCookie(event.cookies, event.url); + return null; } export const load: PageServerLoad = async ({ locals }) => { @@ -72,7 +114,10 @@ export const load: PageServerLoad = async ({ locals }) => { export const actions: Actions = { default: async (event) => { - await performLogout(event); + const sloStateId = await performLogout(event); + if (sloStateId) { + throw redirect(303, `/saml/slo?state=${sloStateId}`); + } throw redirect(303, "/login"); }, }; diff --git a/src/routes/saml/slo/+server.ts b/src/routes/saml/slo/+server.ts index 581cf93..cc06923 100644 --- a/src/routes/saml/slo/+server.ts +++ b/src/routes/saml/slo/+server.ts @@ -1,172 +1,291 @@ /** - * SAML 2.0 Single Logout (SLO) 엔드포인트 — SP-initiated. + * SAML 2.0 Single Logout (SLO) 엔드포인트 — 순차 리다이렉트 체인 구현. * - * GET /saml/slo - * - SAMLRequest(LogoutRequest) 파싱 → SessionIndex 로 해당 SAML 세션 식별 - * - SP 인증서가 등록돼 있으면 HTTP-Redirect 서명 검증 - * - 해당 SAML 세션 종료 (endedAt) - * - 같은 IdP 세션에 묶인 다른 SP 를 대상으로 best-effort 로그아웃 - * (iframe 기반 FC, JS-free; HTTP-Redirect 바인딩 SP 만) - * - OIDC back-channel 로그아웃은 waitUntil 로 발송 - * - IdP 세션 폐기, LogoutResponse 로 원 SP 에 302 + * 한 번의 브라우저 네비게이션에서 여러 SP 를 로그아웃시키기 위해, 체인 상태를 + * `samlSloStates` 테이블에 저장하고 RelayState 로 `samlSloStates.id` 를 전달해 + * 각 SP 의 응답을 받아 다음 SP 로 이어간다. * - * SAMLRequest 가 없으면 단순히 세션만 폐기하고 "/" 로 리다이렉트. + * 처리 분기 (쿼리 파라미터 기준): + * A. SAMLResponse + RelayState : 체인 진행 중인 SP 로부터의 LogoutResponse 수신 + * → 다음 pending SP 로 리다이렉트하거나, 없으면 체인 종료 + * B. state : IdP-initiated SLO 체인 시작 (로그아웃 페이지에서 진입) + * C. SAMLRequest : SP-initiated SLO 시작 + * D. 그 외 : 세션만 폐기하고 / */ import { error, redirect } from "@sveltejs/kit"; -import type { RequestHandler } from "./$types"; -import { and, eq } from "drizzle-orm"; -import { samlSessions, samlSps, sessions } from "$lib/server/db/schema"; +import type { RequestEvent, RequestHandler } from "./$types"; +import { and, eq, gt } from "drizzle-orm"; +import { samlSessions, samlSloStates, samlSps, sessions } from "$lib/server/db/schema"; import { getRequestMetadata, recordAuditEvent } from "$lib/server/audit"; import { requireDbContext } from "$lib/server/auth/guards"; import { SESSION_COOKIE_NAME } from "$lib/server/auth/constants"; import { getActiveSigningKey } from "$lib/server/crypto/keys"; import { getOidcBackchannelTargets, sendOneBackchannelLogout } from "$lib/server/oidc/logout"; -import { buildSamlLogoutRequest, buildSamlLogoutResponse, buildSamlSloRedirectUrl, getActiveSamlSessionsForSession, parseSamlLogoutRequest } from "$lib/server/saml/slo"; +import { buildSamlLogoutRequest, buildSamlLogoutResponse, buildSamlSloRedirectUrl, collectPendingSpData, parseSamlLogoutRequest, type PendingSpData } from "$lib/server/saml/slo"; import { verifySamlRedirectSignature } from "$lib/server/saml/parse-authn-request"; -function htmlEscape(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +const SLO_STATE_TTL_MS = 10 * 60 * 1000; // 10 분 + +function parsePendingSpData(json: string): PendingSpData[] { + try { + const parsed = JSON.parse(json); + if (!Array.isArray(parsed)) return []; + return parsed as PendingSpData[]; + } catch { + return []; + } } -export const GET: RequestHandler = async (event) => { - const { locals, url, cookies, platform } = event; +async function fireOidcBackchannelLogout(event: RequestEvent, idpSession: typeof sessions.$inferSelect): Promise { + const { locals, platform, url } = event; const { db, tenant } = requireDbContext(locals); + const signingKeySecret = locals.runtimeConfig.signingKeySecret; + if (!signingKeySecret) return; - const samlRequest = url.searchParams.get("SAMLRequest"); - const relayState = url.searchParams.get("RelayState"); + const bcTargets = await getOidcBackchannelTargets(db, tenant.id, idpSession.id); + if (bcTargets.length === 0) return; - // SAMLRequest 없으면 세션만 폐기하고 홈으로 (IdP 콘솔 테스트 등) - if (!samlRequest) { - if (locals.session) { - await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, locals.session.id)); - cookies.delete(SESSION_COOKIE_NAME, { path: "/" }); - } - throw redirect(302, "/"); + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + if (!signingKey) return; + + const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; + const bcPromises = bcTargets.map((t) => sendOneBackchannelLogout(t, idpSession.userId, idpSession.idpSessionId, issuerUrl, signingKey.privateKey, signingKey.kid).catch(() => undefined)); + const wait = platform?.ctx?.waitUntil?.bind(platform.ctx); + if (wait) { + wait(Promise.all(bcPromises)); + } else { + await Promise.all(bcPromises); } +} - // 1. LogoutRequest 파싱 - let parsed; - try { - parsed = await parseSamlLogoutRequest(samlRequest); - } catch { - throw error(400, "Invalid SAMLRequest"); +/** + * 체인의 다음 SP 에게 서명된 LogoutRequest 를 보낸다. + * pendingSpDataJson 을 먼저 업데이트한 뒤(처리할 SP 를 맨 앞에서 제거) 리다이렉트한다. + * 이렇게 해야 사용자가 새로 고침해도 같은 SP 가 중복 처리되지 않는다. + */ +async function redirectToNextSp(event: RequestEvent, stateId: string, remaining: PendingSpData[]): Promise { + const { locals, url } = event; + const { db, tenant } = requireDbContext(locals); + + if (remaining.length === 0) { + throw error(500, "체인에 남은 SP 가 없습니다"); } - // 2. SP 조회 (issuer = entityId) - const [sp] = await db - .select() - .from(samlSps) - .where(and(eq(samlSps.tenantId, tenant.id), eq(samlSps.entityId, parsed.issuer), eq(samlSps.enabled, true))) - .limit(1); - if (!sp) { - throw error(400, "Unknown SAML SP"); + const next = remaining[0]; + const rest = remaining.slice(1); + + // 먼저 DB 를 업데이트한다 (next SP 를 pending 에서 제거). + await db + .update(samlSloStates) + .set({ pendingSpDataJson: JSON.stringify(rest) }) + .where(eq(samlSloStates.id, stateId)); + + const signingKeySecret = locals.runtimeConfig.signingKeySecret; + if (!signingKeySecret) { + throw error(500, "서명 키가 설정되지 않아 SLO 체인을 계속 진행할 수 없습니다"); + } + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + if (!signingKey) { + throw error(500, "활성 서명 키가 없습니다"); } - // 3. 서명 검증 (SP cert 가 있으면 필수) - if (sp.cert) { - const rawQuery = url.search.replace(/^\?/, ""); - const valid = await verifySamlRedirectSignature(rawQuery, sp.cert); - if (!valid) { - throw error(400, "Invalid SAMLRequest signature"); - } + const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; + const lrXml = buildSamlLogoutRequest({ + id: `_l${crypto.randomUUID().replace(/-/g, "")}`, + issuerUrl, + destination: next.sloUrl, + nameId: next.nameId, + nameIdFormat: next.nameIdFormat, + sessionIndex: next.sessionIndex, + }); + + const redirectUrl = await buildSamlSloRedirectUrl({ + sloUrl: next.sloUrl, + xml: lrXml, + param: "SAMLRequest", + relayState: stateId, + privateKey: signingKey.privateKey, + }); + + throw redirect(302, redirectUrl); +} + +/** + * 체인 종료 처리: IdP 세션을 폐기하고 쿠키를 제거한 뒤, OIDC BC 로그아웃을 + * waitUntil 로 발송하고 state 행을 삭제한다. 최종 리다이렉트는 호출자가 수행한다. + */ +async function completeSloChain(event: RequestEvent, state: typeof samlSloStates.$inferSelect): Promise { + const { locals, cookies } = event; + const { db, tenant } = requireDbContext(locals); + + // 1. 연결된 IdP 세션 조회 (아직 revoke 되지 않았다면) + const [idpSession] = await db.select().from(sessions).where(eq(sessions.id, state.idpSessionRecordId)).limit(1); + + // 2. 남아있는 SAML 세션 전체를 endedAt 으로 표시 (일관성 유지) + if (idpSession) { + await db.update(samlSessions).set({ endedAt: new Date() }).where(eq(samlSessions.sessionId, idpSession.id)); } - // 4. SessionIndex → SAML 세션 및 IdP 세션 식별 - // (동일 SP 가 여러 SessionIndex 를 보낼 수 있으나 M3 기준 하나만 처리) - const targetSessionIndex = parsed.sessionIndexes[0]; - let idpSession: typeof sessions.$inferSelect | null = null; - let linkedSamlSessionId: string | null = null; - if (targetSessionIndex) { - const [row] = await db - .select({ samlSessionId: samlSessions.id, idpSessionId: samlSessions.sessionId }) - .from(samlSessions) - .where(and(eq(samlSessions.tenantId, tenant.id), eq(samlSessions.spId, sp.id), eq(samlSessions.sessionIndex, targetSessionIndex))) - .limit(1); - if (row) { - linkedSamlSessionId = row.samlSessionId; - if (row.idpSessionId) { - const [s] = await db.select().from(sessions).where(eq(sessions.id, row.idpSessionId)).limit(1); - idpSession = s ?? null; - } - } + // 3. OIDC BC 로그아웃 발송 (waitUntil) + if (idpSession) { + await fireOidcBackchannelLogout(event, idpSession); } - // fallback — 현재 브라우저 세션 - if (!idpSession && locals.session) idpSession = locals.session; - // 5. SAML 세션 종료 - if (linkedSamlSessionId) { - await db.update(samlSessions).set({ endedAt: new Date() }).where(eq(samlSessions.id, linkedSamlSessionId)); + // 4. IdP 세션 폐기 + if (idpSession && !idpSession.revokedAt) { + await db + .update(sessions) + .set({ revokedAt: new Date() }) + .where(and(eq(sessions.id, idpSession.id), eq(sessions.tenantId, tenant.id))); } - // 6. 다른 활성 SAML 세션 수집 (best-effort FC 로그아웃용) - let otherSamlIframeUris: string[] = []; - if (idpSession) { - const siblings = await getActiveSamlSessionsForSession(db, idpSession.id); - const remaining = siblings.filter((s) => s.id !== linkedSamlSessionId); - const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; - const signingKeySecret = locals.runtimeConfig.signingKeySecret; - const signingKey = signingKeySecret ? await getActiveSigningKey(db, tenant.id, signingKeySecret) : null; - - if (signingKey) { - const urls: string[] = []; - for (const s of remaining) { - if (!s.sp.sloUrl) continue; - const lrXml = buildSamlLogoutRequest({ - id: `_l${crypto.randomUUID().replace(/-/g, "")}`, - issuerUrl, - destination: s.sp.sloUrl, - nameId: s.nameId, - nameIdFormat: s.nameIdFormat ?? "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - sessionIndex: s.sessionIndex, - }); - try { - const signed = await buildSamlSloRedirectUrl({ - sloUrl: s.sp.sloUrl, - xml: lrXml, - param: "SAMLRequest", + // 5. 쿠키 제거 + cookies.delete(SESSION_COOKIE_NAME, { path: "/" }); + + // 6. state 행 삭제 + await db.delete(samlSloStates).where(eq(samlSloStates.id, state.id)); +} + +export const GET: RequestHandler = async (event) => { + const { locals, url, cookies } = event; + const { db, tenant } = requireDbContext(locals); + + const samlRequest = url.searchParams.get("SAMLRequest"); + const samlResponse = url.searchParams.get("SAMLResponse"); + const relayState = url.searchParams.get("RelayState"); + const stateParam = url.searchParams.get("state"); + + // ── Case A: SAMLResponse + RelayState → 체인 진행 중인 SP 의 응답 처리 ───── + if (samlResponse && relayState) { + const [state] = await db + .select() + .from(samlSloStates) + .where(and(eq(samlSloStates.id, relayState), eq(samlSloStates.tenantId, tenant.id), gt(samlSloStates.expiresAt, new Date()))) + .limit(1); + if (!state) { + throw error(400, "Invalid or expired SLO state"); + } + + // pendingSpDataJson 에 남아 있는 SP 목록 (현재 응답을 보낸 SP 는 이미 제거된 상태) + const remaining = parsePendingSpData(state.pendingSpDataJson); + + // 다음 SP 가 있으면 이어서 진행 + if (remaining.length > 0) { + await redirectToNextSp(event, state.id, remaining); + // 위에서 redirect throw + } + + // 남은 SP 가 없으면 체인 종료 + await completeSloChain(event, state); + + // SP-initiated 였다면 최초 SP 로 LogoutResponse 를 돌려준다. + if (state.initiatorSloUrl && state.inResponseTo) { + const signingKeySecret = locals.runtimeConfig.signingKeySecret; + if (signingKeySecret) { + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + if (signingKey) { + const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; + const responseXml = buildSamlLogoutResponse({ + id: `_lr${crypto.randomUUID().replace(/-/g, "")}`, + inResponseTo: state.inResponseTo, + issuerUrl, + destination: state.initiatorSloUrl, + status: "Success", + }); + const redirectUrl = await buildSamlSloRedirectUrl({ + sloUrl: state.initiatorSloUrl, + xml: responseXml, + param: "SAMLResponse", privateKey: signingKey.privateKey, }); - urls.push(signed); - } catch { - // skip + throw redirect(302, redirectUrl); } } - otherSamlIframeUris = urls; + // 서명 키가 없으면 completionUri 로 폴백 } - // IdP 관점에서 이 세션에 묶인 모든 SAML 세션을 종료로 표시한다. - // (서명 키가 없어 FC 알림을 못 보내더라도 IdP DB 는 일관성을 유지해야 함.) - await db.update(samlSessions).set({ endedAt: new Date() }).where(eq(samlSessions.sessionId, idpSession.id)); + throw redirect(302, state.completionUri); + } + + // ── Case B: state 파라미터 → IdP-initiated 체인 시작 ──────────────────────── + if (stateParam) { + const [state] = await db + .select() + .from(samlSloStates) + .where(and(eq(samlSloStates.id, stateParam), eq(samlSloStates.tenantId, tenant.id), gt(samlSloStates.expiresAt, new Date()))) + .limit(1); + if (!state) { + throw error(400, "Invalid or expired SLO state"); + } + + const pending = parsePendingSpData(state.pendingSpDataJson); + + if (pending.length === 0) { + // 엣지 케이스: pending 이 없는데 state 만 있음 → 바로 종료 + await completeSloChain(event, state); + throw redirect(302, state.completionUri); + } + + await redirectToNextSp(event, state.id, pending); + // redirect throw } - // 7. OIDC BC 로그아웃 (waitUntil) - if (idpSession && locals.runtimeConfig.signingKeySecret) { - const bcTargets = await getOidcBackchannelTargets(db, tenant.id, idpSession.id); - if (bcTargets.length > 0) { - const signingKey = await getActiveSigningKey(db, tenant.id, locals.runtimeConfig.signingKeySecret); - if (signingKey) { - const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; - const bcPromises = bcTargets.map((t) => - sendOneBackchannelLogout(t, idpSession.userId, idpSession.idpSessionId, issuerUrl, signingKey.privateKey, signingKey.kid).catch(() => undefined), - ); - const wait = platform?.ctx?.waitUntil?.bind(platform.ctx); - if (wait) { - wait(Promise.all(bcPromises)); - } else { - await Promise.all(bcPromises); + // ── Case C: SAMLRequest → SP-initiated SLO 시작 ───────────────────────────── + if (samlRequest) { + let parsed; + try { + parsed = await parseSamlLogoutRequest(samlRequest); + } catch { + throw error(400, "Invalid SAMLRequest"); + } + + // SP 조회 + const [sp] = await db + .select() + .from(samlSps) + .where(and(eq(samlSps.tenantId, tenant.id), eq(samlSps.entityId, parsed.issuer), eq(samlSps.enabled, true))) + .limit(1); + if (!sp) { + throw error(400, "Unknown SAML SP"); + } + + // 서명 검증 (SP cert 가 있으면 필수) + if (sp.cert) { + const rawQuery = url.search.replace(/^\?/, ""); + const valid = await verifySamlRedirectSignature(rawQuery, sp.cert); + if (!valid) { + throw error(400, "Invalid SAMLRequest signature"); + } + } + + // SessionIndex → SAML 세션 및 IdP 세션 식별 + const targetSessionIndex = parsed.sessionIndexes[0]; + let idpSession: typeof sessions.$inferSelect | null = null; + let linkedSamlSessionId: string | null = null; + if (targetSessionIndex) { + const [row] = await db + .select({ samlSessionId: samlSessions.id, idpSessionId: samlSessions.sessionId }) + .from(samlSessions) + .where(and(eq(samlSessions.tenantId, tenant.id), eq(samlSessions.spId, sp.id), eq(samlSessions.sessionIndex, targetSessionIndex))) + .limit(1); + if (row) { + linkedSamlSessionId = row.samlSessionId; + if (row.idpSessionId) { + const [s] = await db.select().from(sessions).where(eq(sessions.id, row.idpSessionId)).limit(1); + idpSession = s ?? null; } } } - } + if (!idpSession && locals.session) idpSession = locals.session; - // 8. IdP 세션 폐기 - if (idpSession) { - await db - .update(sessions) - .set({ revokedAt: new Date() }) - .where(and(eq(sessions.id, idpSession.id), eq(sessions.tenantId, tenant.id))); - if (locals.user) { + // 초기 SP 의 SAML 세션 종료 + if (linkedSamlSessionId) { + await db.update(samlSessions).set({ endedAt: new Date() }).where(eq(samlSessions.id, linkedSamlSessionId)); + } + + // 감사 로그 + if (idpSession && locals.user) { const requestMetadata = getRequestMetadata(event); await recordAuditEvent(db, { tenantId: tenant.id, @@ -179,51 +298,85 @@ export const GET: RequestHandler = async (event) => { detail: { spEntityId: parsed.issuer }, }); } - cookies.delete(SESSION_COOKIE_NAME, { path: "/" }); - } - // 9. LogoutResponse 생성 → SP 로 리다이렉트 - if (!sp.sloUrl) { - throw redirect(302, "/"); - } - const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; - const responseXml = buildSamlLogoutResponse({ - id: `_lr${crypto.randomUUID().replace(/-/g, "")}`, - inResponseTo: parsed.id, - issuerUrl, - destination: sp.sloUrl, - status: "Success", - }); + // 남은 SP 수집 (초기 요청 SP 제외) + const pending: PendingSpData[] = idpSession ? await collectPendingSpData(db, idpSession.id, parsed.issuer) : []; - const signingKeySecret = locals.runtimeConfig.signingKeySecret; - if (!signingKeySecret) { - // 서명키 없으면 부득이하게 미서명 응답은 불가 → 세션만 폐기하고 홈으로 - throw redirect(302, "/"); - } - const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); - if (!signingKey) { - throw redirect(302, "/"); - } - const redirectUrl = await buildSamlSloRedirectUrl({ - sloUrl: sp.sloUrl, - xml: responseXml, - param: "SAMLResponse", - relayState, - privateKey: signingKey.privateKey, - }); + // 남은 SP 가 없으면: 세션 폐기, BC 로그아웃 발송, sp 로 LogoutResponse 반환 + if (pending.length === 0) { + // IdP 세션 일괄 종료 표시 + if (idpSession) { + await db.update(samlSessions).set({ endedAt: new Date() }).where(eq(samlSessions.sessionId, idpSession.id)); + await fireOidcBackchannelLogout(event, idpSession); + if (!idpSession.revokedAt) { + await db + .update(sessions) + .set({ revokedAt: new Date() }) + .where(and(eq(sessions.id, idpSession.id), eq(sessions.tenantId, tenant.id))); + } + } + cookies.delete(SESSION_COOKIE_NAME, { path: "/" }); + + if (!sp.sloUrl) { + throw redirect(302, "/"); + } + const signingKeySecret = locals.runtimeConfig.signingKeySecret; + if (!signingKeySecret) { + throw redirect(302, "/"); + } + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + if (!signingKey) { + throw redirect(302, "/"); + } + const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; + const responseXml = buildSamlLogoutResponse({ + id: `_lr${crypto.randomUUID().replace(/-/g, "")}`, + inResponseTo: parsed.id, + issuerUrl, + destination: sp.sloUrl, + status: "Success", + }); + const redirectUrl = await buildSamlSloRedirectUrl({ + sloUrl: sp.sloUrl, + xml: responseXml, + param: "SAMLResponse", + relayState, + privateKey: signingKey.privateKey, + }); + throw redirect(302, redirectUrl); + } + + // 남은 SP 가 있으면: samlSloState 생성 후 첫 SP 로 체인 시작 + if (!idpSession) { + // 이론적으로 pending.length > 0 이려면 idpSession 이 있었어야 한다. + throw error(500, "SLO 체인 초기화 실패"); + } - // 다른 SP 들이 남아 있으면 iframe 으로 선 로그아웃 시도 후 3초 뒤 리다이렉트 - if (otherSamlIframeUris.length > 0) { - const iframes = otherSamlIframeUris.map((u) => ``).join(""); - const html = - `` + - `Logging out...` + - `` + - `

로그아웃 중...

${iframes}`; - return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } }); + const stateId = crypto.randomUUID(); + const nowMs = Date.now(); + await db.insert(samlSloStates).values({ + id: stateId, + tenantId: tenant.id, + idpSessionRecordId: idpSession.id, + userId: idpSession.userId, + initiatingSpEntityId: parsed.issuer, + inResponseTo: parsed.id, + initiatorSloUrl: sp.sloUrl ?? null, + completionUri: sp.sloUrl ?? "/login", + pendingSpDataJson: JSON.stringify(pending), + expiresAt: new Date(nowMs + SLO_STATE_TTL_MS), + }); + + await redirectToNextSp(event, stateId, pending); + // redirect throw } - throw redirect(302, redirectUrl); + // ── Case D: 그 외 fallback — 세션만 폐기하고 / 로 ─────────────────────────── + if (locals.session) { + await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, locals.session.id)); + cookies.delete(SESSION_COOKIE_NAME, { path: "/" }); + } + throw redirect(302, "/"); }; export const POST: RequestHandler = (event) => GET(event); From df36d33726f25691a0844e1e1e148e83e8227d1c Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Tue, 21 Apr 2026 10:03:05 +0900 Subject: [PATCH 18/18] =?UTF-8?q?feat:=20SAML=20ACS=20=EB=B0=8F=20OIDC=20?= =?UTF-8?q?=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20=EC=B2=B4?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20=ED=97=88=EC=9A=A9=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20form-action=20CSP=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- svelte.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/svelte.config.js b/svelte.config.js index 66e3fc8..9318f03 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -18,7 +18,7 @@ const config = { "font-src": ["self", "data:"], "connect-src": ["self"], "frame-ancestors": ["none"], - "form-action": ["self"], // SAML ACS auto-submit은 raw Response라 SvelteKit CSP 비적용 + "form-action": ["self", "https:", "http://localhost:*"], // SAML ACS + OIDC 리다이렉트 체인 허용 (Chrome은 form-action을 redirect chain 전체에 적용) "base-uri": ["self"], "object-src": ["none"], },