Cloudflare Workers 위에서 동작하는 오픈소스 Identity Provider입니다. OIDC, SAML 2.0, WebAuthn/Passkey, TOTP 2FA, LDAP 연동을 지원하며 멀티테넌트 조직 관리를 포함합니다.
개발 단계 안내: 본 프로젝트는 활발히 개발 중인 학습/실험 성격의 IdP입니다. 프로덕션 도입 전에는 위협 모델에 맞춘 자체 보안 검토를 권장합니다. 보안 관련 알려진 한계는 보안 참고사항을 참조하세요.
| 기능 | 설명 |
|---|---|
| OIDC | Authorization Code + PKCE, Refresh Token, UserInfo, JWKS, End-Session |
| SAML 2.0 | SP-Initiated SSO, HTTP-POST 바인딩, ForceAuthn, IsPassive, RequestedAuthnContext, SLO |
| ACR / AMR | 인증 방식에 따른 ACR 자동 결정 — SAML Assertion 및 OIDC ID Token에 포함 |
| WebAuthn / Passkey | 패스키 등록 및 인증, challenge 1회용 DB 처리, 테넌트 격리 |
| TOTP 2FA | Google Authenticator 등 호환, 백업 코드 지원 |
| LDAP 연동 | LDAP 인증 및 JIT 사용자 프로비저닝, 관리자 UI에서 프로바이더 설정 |
| 계정 자가 관리 | 프로필 편집, 비밀번호 재설정/찾기, MFA 등록, Passkey 등록·해제 |
| 조직 관리 | 부서 → 팀 → 파트 계층, 직급/직책, 복수 소속 |
| 멀티테넌트 | 테넌트별 독립 사용자/클라이언트/키 관리 |
| 관리자 UI | 사용자, 조직(부서·팀·파트·직급), OIDC 클라이언트, SAML SP, LDAP 프로바이더, 서명 키, 로그인 스킨, 감사 로그 CRUD |
| 커스텀 로그인 스킨 | OIDC 클라이언트별 커스텀 CSS/스크립트, R2 캐시로 배포 |
| 감사 로그 | 로그인, SSO, 토큰 발급 등 주요 이벤트 자동 기록, 관리자 UI에서 조회 가능 |
| 국제화 | 메시지 카탈로그 기반 i18n (현재 한국어 제공, 다국어 확장 가능) |
세션의 인증 방식(AMR)에 따라 ACR이 자동으로 결정되어 SAML Assertion 및 OIDC ID Token에 포함됩니다.
| 인증 방식 | AMR 값 | ACR 값 |
|---|---|---|
| 비밀번호만 | pwd |
urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport |
| 비밀번호 + TOTP | pwd, totp |
https://refeds.org/profile/mfa |
| 비밀번호 + 백업 코드 | pwd, swk |
https://refeds.org/profile/mfa |
| WebAuthn / Passkey | hwk |
https://refeds.org/profile/mfa |
SAML SP가 RequestedAuthnContext로 특정 ACR을 요구하는 경우, 세션 ACR이 해당 수준을 만족하지 않으면 재인증이 강제됩니다. MFA 미설정 등으로 만족 불가 시 NoAuthnContext 오류가 ACS URL로 반환됩니다.
각 SP에 allowedAttributes 목록을 설정하여 Assertion에 포함할 속성을 제어할 수 있습니다. 미설정 시 email, username, displayName만 기본 포함됩니다. 조직 정보(department, team, jobTitle, position)는 SP가 명시적으로 허용한 경우에만 포함됩니다.
- Runtime: Cloudflare Workers(기본,
nodejs_als·nodejs_compat) 또는 순수 Node 서버(adapter-node,BUILD_TARGET=node) — 배포 타깃 참고 - Framework: SvelteKit 2 + Svelte 5 (runes),
@sveltejs/adapter-cloudflare - Database: Cloudflare D1(SQLite) / libSQL(SQLite) / PostgreSQL / MySQL 중 택1 (Drizzle ORM). PostgreSQL·MySQL 은 Hyperdrive 또는
DATABASE_URL직결 (DB 방언 선택 참고) - Object Storage: Cloudflare R2 또는 S3 호환(AWS S3·MinIO 등) — 커스텀 로그인 스킨 캐시
- Styling: Tailwind CSS 4
- Crypto: Web Crypto API (RSA/EC 서명, argon2id 비밀번호 해시),
@simplewebauthn/*,xmldsigjs - Language / Tooling: TypeScript, Bun, ESLint, Prettier
src/
├── hooks.server.ts # 세션 복원, 보안 헤더, 테넌트 컨텍스트
├── app.html
├── routes/
│ ├── (auth)/ # login, signup, logout, mfa, find-id, find-password, reset-password
│ ├── account/ # profile, mfa, passkeys (계정 자가 관리)
│ ├── admin/ # 관리자 UI (users, departments, teams, parts, positions,
│ │ # oidc-clients, saml-sps, ldap-providers,
│ │ # signing-keys, skins, audit, login)
│ ├── oidc/ # authorize, token, userinfo, jwks, end-session
│ ├── saml/ # sso, slo, metadata
│ └── api/ # webauthn/*, skin-scripts/*
└── lib/
├── i18n/ # 메시지 카탈로그 (ko.json)
├── assets/
└── server/
├── auth/ # session, password, mfa, totp, webauthn, guards, bootstrap
├── oidc/ # client, grant, pkce, logout
├── saml/ # sp, metadata, parse-authn-request, response, slo
├── ldap/ # auth, client, provision
├── crypto/ # 서명 키 관리, JWT 발급, 키 회전
├── audit/ # 감사 이벤트 기록
├── org/ # 조직 멤버십 조회
├── ratelimit/ # 인증 엔드포인트 레이트 리밋
├── skin/ # 커스텀 로그인 스킨
└── db/ # Drizzle 스키마 및 D1 초기화
drizzle/ # 마이그레이션 SQL (drizzle-kit generate 산출물)
docs/ # 설계/감사 문서
scripts/setup.ts # 대화형 초기 셋업 스크립트
| 경로 | 설명 |
|---|---|
/.well-known/openid-configuration |
OIDC Discovery 문서 |
/oidc/authorize |
인증 요청 (Authorization Code + PKCE) |
/oidc/token |
토큰 교환 (Authorization Code, Refresh Token) |
/oidc/userinfo |
UserInfo 엔드포인트 |
/oidc/jwks |
JSON Web Key Set |
/oidc/end-session |
RP-Initiated Logout |
| 경로 | 설명 |
|---|---|
/saml/metadata |
IdP 메타데이터 XML |
/saml/sso |
SP-Initiated SSO (AuthnRequest 처리) |
/saml/slo |
Single Logout (체인 SLO 지원) |
| 경로 | 설명 |
|---|---|
/api/webauthn/register/options |
Passkey 등록 challenge 발급 |
/api/webauthn/register/verify |
Passkey 등록 attestation 검증 |
/api/webauthn/authenticate/options |
Passkey 인증 challenge 발급 |
/api/webauthn/authenticate/verify |
Passkey 인증 assertion 검증 |
/api/skin-scripts/* |
OIDC 클라이언트별 커스텀 스킨 스크립트 |
stardust dispatcher 같은 신뢰된 서비스가 사용자 대신 TOTP 등록 / 검증을 위탁하기 위한 API. 모든 요청은 Authorization: Bearer $DISPATCHER_SERVICE_TOKEN 헤더 필요 (constant-time 비교).
| 경로 | 메서드 | 설명 |
|---|---|---|
/api/totp/enroll/init |
POST | {userId} → {secret, otpAuthUri, username} (stateless) |
/api/totp/enroll/confirm |
POST | {userId, secret, code} → 검증 + 영구 저장 + {backupCodes} |
/api/totp/verify |
POST | {userId, code} → step-up 검증 + {ok, verifiedAt} |
/api/totp/status |
GET | ?userId=... → {enrolled, backupCodeCount, lastUsedAt} |
- Bun 1.x
- Cloudflare 계정 (D1, R2, Workers 활성화)
- Wrangler CLI (
bun add -g wrangler)
git clone https://github.com/mack-erel/idp.git
cd idp
bun install
bun run setupbun run setup은 아래 과정을 대화형으로 안내합니다. DB 방언은 --dialect(d1(기본) | postgres | mysql | sqlite) 또는 DB_DIALECT 환경변수로 선택하며, 방언에 따라 3~5단계가 갈라집니다:
- 설정 파일 생성 —
wrangler.example.jsonc→wrangler.jsonc,.env.example→.env - DB 방언 결정 —
--dialect>DB_DIALECTenv >.env> 프롬프트 - DB 설정
d1: wrangler 로그인 확인 후 D1 새로 생성 또는 기존 DB 선택 (프리뷰 DB 선택적), DB ID·계정 ID를wrangler.jsonc/.env에 자동 기입postgres/mysql/sqlite:DATABASE_URL입력(또는 기존 값 재사용), postgres/mysql 은 Hyperdrive 구성 ID 입력 시wrangler.jsonc의HYPERDRIVE바인딩 자동 설정
- 마이그레이션 — 방언별
db:generate*실행 후 스키마 적용 (D1 은 충돌 테이블 감지 및 처리 포함) - 초기 관리자 계정 생성 — 조직명, 관리자 계정 정보, Issuer URL 입력 후 DB에 시드 (비밀번호 미입력 시 자동 생성)
- 서명 키 —
IDP_SIGNING_KEY_SECRET자동 생성 또는 직접 입력 후.env에 저장
# 예: PostgreSQL 로 셋업
bun run setup -- --dialect postgres --database-url "postgres://user:pass@host:5432/db" --hyperdrive-id <id>R2 버킷(
keystone-skin-cache)은 커스텀 로그인 스킨 기능을 사용할 때 필요합니다. 사용하지 않는다면wrangler.jsonc의r2_buckets항목을 주석 처리해도 됩니다.
셋업 완료 후 로컬 개발 서버를 시작합니다:
bun run devbun run deploy배포 전 Wrangler Secret으로 민감한 값을 설정합니다:
wrangler secret put IDP_SIGNING_KEY_SECRET참고: 로컬 개발에서는
.env에 평문으로 저장해도 무방하지만, 프로덕션에서는 반드시 Secret으로 관리하세요.
| 변수 | 필수 | 설명 |
|---|---|---|
IDP_ISSUER_URL |
✅ | OIDC/SAML 발급자 URL (배포 도메인과 일치). 프로덕션 필수 — 미설정 시 요청 초기 503(fail-closed). dev 에서만 요청 origin 자동 대체 |
IDP_SIGNING_KEY_SECRET |
✅ | 서명 키 암호화 KEK (프로덕션은 반드시 Secret). 프로덕션 필수 — 미설정 시 요청 초기에 오류로 차단(fail-fast) |
DISPATCHER_SERVICE_TOKEN |
선택 | stardust dispatcher 가 /api/totp/* 호출 시 사용할 Bearer 토큰. 미설정이면 해당 API 503 |
IDP_DEFAULT_TENANT_NAME |
선택 | 기본 테넌트 이름 (기본: My Organization) |
CLOUDFLARE_ACCOUNT_ID |
선택 | Cloudflare 계정 ID (마이그레이션 스크립트에서 사용) |
CLOUDFLARE_D1_DATABASE_ID |
선택 | D1 데이터베이스 ID (마이그레이션 스크립트에서 사용) |
CLOUDFLARE_D1_PREVIEW_DATABASE_ID |
선택 | 프리뷰용 D1 데이터베이스 ID |
CLOUDFLARE_D1_TOKEN |
선택 | D1 API 토큰 (db:migrate 스크립트에서 사용) |
참고: 초기 관리자 계정은
bun run setup이 생성합니다. 수동/CI 시드가 필요하면IDP_BOOTSTRAP_ADMIN_USERNAME/IDP_BOOTSTRAP_ADMIN_EMAIL/IDP_BOOTSTRAP_ADMIN_PASSWORD(+선택IDP_BOOTSTRAP_ADMIN_NAME) 를 설정하고bun run db:seed(방언별:db:seed:pg등)를 실행하세요. 비대화 환경에서는SEED_RESET=0|1로 초기화 여부를 지정합니다.
| 바인딩 | 종류 | 용도 |
|---|---|---|
DB |
D1 | 메인 데이터베이스 (DB_DIALECT=d1 일 때) |
HYPERDRIVE |
Hyperdrive | PostgreSQL/MySQL 연결 (DB_DIALECT=postgres|mysql 일 때) |
SKIN_CACHE |
R2 | 커스텀 로그인 스킨 캐시 |
ASSETS |
정적 | SvelteKit 빌드 산출물 (.svelte-kit/cloudflare) |
| 명령 | 설명 |
|---|---|
bun run dev |
Vite 개발 서버 실행 |
bun run build |
프로덕션 빌드 |
bun run preview |
Wrangler로 빌드 산출물 미리보기 (localhost:4173) |
bun run check |
wrangler types + svelte-check 타입 검사 |
bun run lint |
Prettier + ESLint 검사 |
bun run format |
Prettier 자동 포맷 |
bun run gen |
Wrangler 환경 타입 재생성 |
bun run db:generate |
Drizzle 마이그레이션 SQL 생성 (D1) |
bun run db:generate:sqlite |
Drizzle 마이그레이션 SQL 생성 (libSQL/SQLite) |
bun run db:generate:pg |
Drizzle 마이그레이션 SQL 생성 (PostgreSQL) |
bun run db:generate:mysql |
Drizzle 마이그레이션 SQL 생성 (MySQL) |
bun run db:migrate |
마이그레이션 적용 + seed 마이그레이션 (D1) |
bun run db:migrate:pg |
마이그레이션 적용 + seed 마이그레이션 (PostgreSQL) |
bun run db:seed |
기본 데이터 시드 — 테넌트/관리자/서비스 role (D1) |
bun run db:seed:pg |
기본 데이터 시드 (PostgreSQL) |
bun run db:studio |
Drizzle Studio 실행 (스키마/데이터 GUI) |
bun run deploy |
Cloudflare Workers 배포 |
db:migrate/db:seed/db:push/db:studio는:pg/:mysql/:sqlite접미사로 방언별 실행이 가능합니다. postgres/mysql/sqlite 는DATABASE_URL, D1 은CLOUDFLARE_*환경변수를 사용합니다.scripts/seed.ts와scripts/seed-migrate.ts는 방언 무관(공용 헬퍼scripts/lib/db.ts)이며, D1 은 REST API 로 접근합니다.
D1(SQLite)·libSQL(SQLite)·PostgreSQL·MySQL 을 지원하며, 배포 단위로 하나만 사용합니다. D1 은 편의성이 높지만 지연이 큰 편이라, 낮은 지연이 필요하면 Cloudflare Hyperdrive 로 PostgreSQL/MySQL 에 연결하거나 자체 DB 에 직결할 수 있습니다.
방언은 DB_DIALECT 환경변수로 선택합니다 (d1(기본) | sqlite | postgres | mysql). 이 값은 런타임뿐 아니라 빌드·타입체크·마이그레이션 생성 시점에도 참조되어 스키마·드라이버를 결정합니다.
| 방언 | 드라이버 | 연결 대상 |
|---|---|---|
d1 |
drizzle-orm/d1 |
Cloudflare D1 (Workers 전용 바인딩) |
sqlite |
libSQL | 로컬 파일(file:) 또는 Turso (순수 Node 등) |
postgres |
postgres-js | Hyperdrive 또는 DATABASE_URL 직결 |
mysql |
mysql2 | Hyperdrive 또는 DATABASE_URL 직결 |
# PostgreSQL 로 빌드·배포
DB_DIALECT=postgres bun run build
DB_DIALECT=postgres bun run deploy # 또는 wrangler deploy
# MySQL 로 빌드·배포
DB_DIALECT=mysql bun run build
# 로컬 SQLite 파일(libSQL) 로 Node 구동
BUILD_TARGET=node DB_DIALECT=sqlite DATABASE_URL="file:./keystone.db" bun run build && node build동작 방식:
- 스키마는 방언별로 분리되어 있습니다:
schema.sqlite.ts(d1·sqlite 공용) /schema.pg.ts/schema.mysql.ts. 세 스키마는 테이블·컬럼·인덱스명과 JS 추론 타입이 동일하게 유지되며, 배럴schema.ts가DB_DIALECT에 맞는 파일로 해석됩니다. src/lib/server/db/index.ts의getDb()가 방언에 맞는 드라이버(d1 / libSQL / postgres-js / mysql2)를 선택합니다. 빌드 시 활성 방언의 드라이버만 번들에 포함됩니다.- 연결 문자열 우선순위:
sqlite는DATABASE_URL/SQLITE_URL(file:스킴 없으면 로컬 파일로 간주).postgres/mysql은 Cloudflare 에서HYPERDRIVE바인딩 →DATABASE_URL(var/secret), 순수 Node 에서DATABASE_URL.
# (Cloudflare) Hyperdrive 구성 생성 (binding 이름은 반드시 HYPERDRIVE)
wrangler hyperdrive create keystone-pg --connection-string="postgres://user:pass@host:5432/db"
wrangler hyperdrive create keystone-mysql --connection-string="mysql://user:pass@host:3306/db"
# → 출력된 id 를 wrangler.jsonc 의 hyperdrive[].id 에 채우고 주석 해제
# (또는 bun run setup -- --dialect postgres --hyperdrive-id <id> 로 자동 설정)
# (Hyperdrive 없이 직결하려면 DATABASE_URL 을 var/secret 으로 설정)
# 스키마 적용 + 시드 (PostgreSQL 예시 — DATABASE_URL 사용)
bun run db:generate:pg && bun run db:migrate:pg
bun run db:seed:pgBUILD_TARGET(cloudflare(기본) | node)으로 어댑터를 전환합니다. node 는 @sveltejs/adapter-node 로 빌드되어 Cloudflare 없이 순수 Node 서버로 구동됩니다. Node 에는 platform 바인딩이 없으므로 PostgreSQL/MySQL/sqlite(libSQL) 를 사용하며(D1 은 Workers 전용), 연결은 DATABASE_URL, 설정값은 process.env 로 읽습니다.
# PostgreSQL + 순수 Node 로 빌드·구동
export BUILD_TARGET=node DB_DIALECT=postgres
export DATABASE_URL="postgres://user:pass@host:5432/db"
# 필요한 설정도 환경변수로: IDP_SIGNING_KEY_SECRET, IDP_ISSUER_URL, IDP_DEFAULT_TENANT_NAME ...
bun run build
node build # adapter-node 산출물 (기본 PORT=3000)참고: MySQL 은
UPDATE ... RETURNING과 partial(부분) unique index 를 지원하지 않습니다. 해당 로직(인가 코드/WebAuthn challenge 소진, signing key 회전, rate limit upsert)은 방언별로 분기되어 MySQL 에서는affectedRows판정·재조회와 트랜잭션으로 동등하게 동작합니다.
커스텀 로그인 스킨 캐시는 다음 우선순위로 스토리지를 선택합니다 (미설정 시 캐시 없이 매번 원본 fetch — 정상 동작).
- Cloudflare R2 바인딩(
SKIN_CACHE) 이 있으면 R2 사용. - 없고 S3 호환 설정(
S3_ENDPOINT/S3_BUCKET/S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY)이 있으면 S3 호환 스토리지 사용.
R2 자체가 S3 호환이므로 AWS S3·MinIO·Ceph 는 물론 R2 의 S3 endpoint 도 그대로 쓸 수 있습니다. 서명은 aws4fetch 로 하며 Workers/Node 양쪽에서 동작합니다. path-style/virtual-host 는 S3_FORCE_PATH_STYLE(기본 true, MinIO/R2 권장)로 제어합니다. 자세한 변수는 .env.example 참고.
스키마 변경 → 마이그레이션 생성 → 적용의 흐름은 다음과 같습니다.
# 1. 사용하는 방언의 스키마 파일 수정
# - D1 / sqlite → src/lib/server/db/schema.sqlite.ts (공용)
# - PostgreSQL → src/lib/server/db/schema.pg.ts
# - MySQL → src/lib/server/db/schema.mysql.ts
# (세 스키마의 구조/타입은 동일하게 유지할 것)
# 2. SQL 생성 (사용하는 방언에 맞게)
bun run db:generate # D1 → drizzle/*.sql
bun run db:generate:sqlite # libSQL → drizzle/sqlite/*.sql (DDL 은 D1 과 동일)
bun run db:generate:pg # PostgreSQL → drizzle/pg/*.sql
bun run db:generate:mysql # MySQL → drizzle/mysql/*.sql
# 3. 생성된 SQL 파일 검토
# 4. 원격 DB에 적용 (사용자가 직접 실행)
bun run db:migrate # D1 프로덕션
bun run db:migrate:preview # D1 프리뷰
# PostgreSQL/MySQL 은 DATABASE_URL 설정 후 drizzle-kit migrate 로 적용
⚠️ 원격 D1에 대한 마이그레이션 적용은 되돌리기 어려우므로, 자동화된 스크립트나 에이전트가 임의로 실행하지 않도록 운영 정책에 포함시키는 것을 권장합니다.
신규 비밀번호는 argon2id (@hicaru/argon2-pure.js, Workers 호환 순수 JS 구현)로 해싱됩니다. 과거 PBKDF2-SHA256 (100,000 iterations)으로 저장된 레거시 해시는 로그인 검증 시 자동으로 argon2id로 재해싱됩니다.
OIDC ID Token 및 SAML Response 서명에 사용되는 RSA 키는 IDP_SIGNING_KEY_SECRET으로 암호화되어 D1에 저장됩니다. 이 시크릿이 유출되면 모든 서명 키가 복호화될 수 있으므로 반드시 강한 랜덤값(openssl rand -base64 32)을 사용하고 정기적으로 교체하세요. 키 회전은 관리자 UI(/admin/signing-keys)에서 수행할 수 있습니다.
WebAuthn 등록·인증 challenge는 D1에 1회용으로 저장되며, 소진 즉시 삭제됩니다. challenge는 테넌트 ID로 격리되어 다른 테넌트의 challenge를 재사용할 수 없습니다.
LDAP 인증 성공 시, 동일 이메일의 기존 로컬 계정이 있는 경우 자동 연결하지 않습니다. LDAP 프로바이더가 이메일을 조작해 기존 관리자 계정을 탈취하는 것을 방지하기 위함입니다. 기존 로컬 계정과의 연결은 관리자가 직접 수행해야 합니다.
hooks.server.ts에서 모든 응답에 다음 헤더를 적용합니다.
X-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originStrict-Transport-Security(HSTS)Permissions-Policy(camera/microphone/geolocation/payment 비활성)- 해시 기반
Content-Security-Policy
초기 관리자 계정은 bun run setup 실행 시 D1에 직접 삽입됩니다. 셋업 완료 후 가능한 빨리 비밀번호를 변경하고 MFA를 설정하는 것을 권장합니다.