<!-- canonical: https://docs.axelabs.ai/architecture/platform-identity -->
<!-- source: content/architecture/platform-identity.mdx -->

---
title: 플랫폼 신원 (Blueprint = OIDC Provider)
description: Blueprint 가 Entra 를 federate 하고 플랫폼 토큰을 발행 — 로그인 1회로 전 서비스. (D-axe-idp-1 — Phase 1+2 LIVE)
---

# 플랫폼 신원 — Blueprint = OIDC Provider

> **상태**: **Phase 1+2 LIVE** (2026-06-04). Blueprint OP(discovery·jwks·authorize·token) + RS256 서명키(vault `blueprint/axe/oidc-signing-key`) + `axe login` loopback-PKCE + **frame·hive·cortex·index·matrix + Blueprint 자체 MCP — 6개 서비스 전부 Blueprint 토큰 신뢰** (영속 설정, e2e 검증). `axe login` → `axe <svc> tools` GREEN. 본 페이지는 [D-axe-idp-1](/ops/decisions) 설계 SSOT 이자 현행 구현 기준.
> ⚠️ **발행자 자신이 마지막 resource server였다** (2026-06-04 fix): Blueprint MCP 는 토큰을 *발행*하면서도 *검증*은 Microsoft Entra 경로만 알아, 자기 플랫폼 토큰을 `unknown_kid` 로 401 했다 (kid = Blueprint OIDC 서명키, Microsoft JWKS 에 부재). frame 의 `auth_blueprint.py` + iss-dispatch 를 미러해 Blueprint MCP 도 자기 issuer 의 resource server 로 배선 (`BLUEPRINT_ISSUER=https://blueprint.axellc.com`). 교훈: OP 를 세울 때 **그 OP 의 자체 MCP 도 resource-server 목록에 포함**해야 한다.
> 비파괴 cutover 유지 (`BLUEPRINT_ISSUER` 미설정 시 서비스는 종전 Microsoft 경로). **인가 중앙화**(entity grant 토큰 삽입)는 여전히 Phase 3 — 현재는 인증만 Blueprint, 인가는 각 서비스 customers.yaml.

## 본질

오늘 외부/멀티에이전트 접근의 마찰은 **서비스마다 따로 인증**한다는 데서 온다. 직원·에이전트가
frame·hive·index·cortex·matrix 를 쓰려면 서비스별 MCP OAuth 를 각각 통과하고, frame·cortex 는
각자 [OAuth-RP 프록시](/architecture/auth) (D-ops-14/15) 를 따로 운영한다. 신원은 **N 곳에 흩어져** 있다.

**목표 한 줄**: 사람은 **한 번** SSO 로그인하고, 그 결과로 받은 **하나의 플랫폼 토큰**으로 **모든 서비스**를 쓴다.
거버넌스(누가·어떤 스코프·취소·감사)는 **Blueprint 한 곳**에 모인다.

그 한 곳이 **Blueprint** 인 이유:
- Blueprint 는 **이미 Entra 를 federate** 한다 (NextAuth Azure AD provider, [`src/lib/auth.ts`](/architecture/auth)). 신규 IdP 를 세우는 게 아니라 **기존 세션 위에 토큰 발행만 얹는다**.
- Blueprint 는 **이미 per-user 권한의 권위자**다 — `entityScopes` · `EntityRole` · `getEntityRolesForUser` 를 들고 있고, frame/hive 가 `/api/internal/entity-roles` 로 **이미 그걸 물어본다**. 토큰 거버넌스의 자연스러운 자리.
- Blueprint 는 **control plane** (구동 시스템). 서비스가 신뢰할 단일 발행자로 토폴로지상 맞다.

## 현재 상태 — 검증된 인증 표면 (2026-06-03 실측)

각 서비스가 incoming 토큰을 검증하는 방식. "Blueprint 신뢰" 추가 시 바꿀 지점과 난이도를 함께 표기.

| 서비스 | 스택 | validator (file) | iss-dispatch seam | HS256 경로 | "Blueprint 신뢰" 난이도 |
|---|---|---|---|---|---|
| **frame** | Python FastMCP | `src/frame/mcp/http_server.py:438` | ✅ `_is_microsoft_iss` → else HS256 | ✅ `FRAME_JWT_SECRET` | **낮음** — `elif _is_blueprint_iss` 한 가지 추가 |
| **hive** | Python FastMCP (frame fork) | `src/hive/mcp/http_server.py:250` | ✅ `_is_microsoft_iss` → else HS256 | ✅ `HIVE_JWT_SECRET` | **낮음** — frame 과 1:1 동일 패턴 |
| **cortex** | Rust axum | `src/auth.rs:214` | △ HS256 short-circuit → RS256 (MS hardcoded) | ✅ `CORTEX_JWT_SECRET` (배선됨) | **중간** — issuer 파라미터화 |
| **index** | Rust axum | `src/auth.rs:214` | ✗ RS256(MS) hardcoded, fallback 없음 | △ `INDEX_JWT_SECRET` 정의만·**미배선** | **중간** — issuer 파라미터화 + HS256 배선 |
| **matrix** | Rust axum (custom) | `src/auth.rs:17` | ✗ HS256 단일, iss 미검증 | ✅ `MATRIX_JWT_SECRET` 만 | **높음** — async refactor + RS256 + JWKS |
| **blueprint (자체 MCP)** | Python FastMCP | `mcp/src/blueprint_mcp/mcp/http_server.py` | ✅ iss-dispatch 추가됨 (2026-06-04) — Blueprint vs Microsoft | ✗ HS256 경로 없음 (Microsoft + Blueprint 만) | **낮음 (완료)** — frame `auth_blueprint.py` 미러. *발행자 본인이라 누락됐던 행* |

**핵심 발견**:
- frame·hive 는 이미 **iss 로 verifier 를 고르는 dispatch seam** 이 있다 — Blueprint 분기는 한 줄. Phase 1 첫 타깃 = **frame** (seam + `auth_oidc.py` JWKS 머신 재사용 + 이미 CLI GREEN 검증됨).
- Blueprint 측 OP 머신(`SignJWT`/JWKS endpoint/authorize/token)은 **전무** (greenfield). 단 `jose` 는 이미 dependency 이고 검증측 `createRemoteJWKSet` 패턴이 `src/lib/mcp-microsoft-rp.ts` 에 존재.
- customers.yaml 의 tenant 는 **고객사별로 다름** (realchoice = 별도 Entra tenant). → **OIDC-OP 는 per-deployment**: 각 고객 Blueprint = 자기 Entra 를 federate 하는 자기 issuer. AXE 자체 issuer = `https://blueprint.axellc.com`.

## 목표 아키텍처

```
        ┌─────────────────────── 사람 SSO 1회 ───────────────────────┐
        │                                                            ▼
  axe login (CLI)                                         ┌──────────────────────┐
  loopback PKCE                                           │  Microsoft Entra ID  │
        │                                                 │  (customer tenant)   │
        │ 1. open browser /oauth/authorize                └──────────┬───────────┘
        ▼                                                            │ NextAuth (기존)
  ┌───────────────────────── Blueprint = OIDC Provider ─────────────┴──┐
  │  GET  /.well-known/openid-configuration   (discovery)              │
  │  GET  /.well-known/jwks.json              (공개키 — 서비스가 fetch)  │
  │  GET  /oauth/authorize   getServerSession 재사용 → code (PKCE)      │
  │  POST /oauth/token       code+verifier → 플랫폼 JWT(RS256)+refresh  │
  │  POST /oauth/register    DCR (loopback + claude.ai allowlist)      │
  │  POST /oauth/revoke      refresh 취소                               │
  │  거버넌스: scope · EntityRole · 발행 audit · per-user/tenant         │
  └───────────────────────────────┬───────────────────────────────────┘
        2. 플랫폼 토큰 (keychain)   │  iss=blueprint, aud=platform, RS256
        ▼                          ▼  (서비스는 Blueprint JWKS 로 검증)
  ┌──────────┐  Bearer  ┌────────┬────────┬────────┬────────┬────────┐
  │  axe CLI │ ───────► │ frame  │  hive  │ index  │ cortex │ matrix │
  └──────────┘  하나의   └────────┴────────┴────────┴────────┴────────┘
                토큰으로 전 서비스       iss=blueprint 분기 → Blueprint JWKS RS256 검증
```

**원칙**:
1. **Entra federation 은 재사용** — `/oauth/authorize` 가 `getServerSession(authOptions)` 로 기존 NextAuth 세션을 확인. 세션 있으면(=이미 Entra SSO 됨) code 발행, 없으면 `/login` 으로 bounce 후 복귀. Blueprint 가 Entra 와 직접 서버-투-서버 code 교환을 다시 짤 필요 없음 (frame `oauth.py` 의 `/oauth/callback` 단계가 Blueprint 에선 불필요 — 이게 frame 대비 단순화).
2. **하나의 토큰, 전 서비스** — `aud` = 플랫폼 audience 단일값. 서비스별 audience juggling 없음. 서비스 권한은 **scope** 가 가른다.
3. **서비스는 서명만 신뢰** — 각 서비스는 `iss=blueprint` 분기에서 Blueprint JWKS 로 RS256 검증. Phase 1 에선 **인증**만 Blueprint 로 (email vouch); **인가**(email→entity) 는 각 서비스 customers.yaml 그대로 → 비파괴. (entity grant 를 토큰에 심는 **인가 중앙화**는 Phase 3.)

## OIDC-OP 엔드포인트 (Blueprint Next.js 에 신설)

모두 `https://blueprint.axellc.com` (= `BLUEPRINT_OIDC_ISSUER`, 기본 `NEXTAUTH_URL`) origin 에 App Router route handler 로 추가. 전부 **공개**(인증 면제) — 단 authorize 는 세션을 요구.

| 엔드포인트 | 메서드 | 신규 파일 (제안) | 역할 |
|---|---|---|---|
| `/.well-known/openid-configuration` | GET | `src/app/.well-known/openid-configuration/route.ts` | RFC 8414 metadata (issuer, endpoints, jwks_uri, S256, RS256) |
| `/.well-known/jwks.json` | GET | `src/app/.well-known/jwks.json/route.ts` | 공개키 (서비스·CLI 가 RS256 검증) |
| `/oauth/authorize` | GET | `src/app/oauth/authorize/route.ts` | 세션 확인(getServerSession) → 단발 code 발행 (PKCE S256 필수) |
| `/oauth/token` | POST | `src/app/oauth/token/route.ts` | `authorization_code` + `refresh_token` grant → 플랫폼 JWT |
| `/oauth/register` | POST | `src/app/oauth/register/route.ts` | RFC 7591 DCR (loopback + claude.ai redirect allowlist) |
| `/oauth/revoke` | POST | `src/app/oauth/revoke/route.ts` | refresh token 취소 (governance) |
| `/oauth/device_authorization` | POST | (Phase 3) | RFC 8628 device-code (headless) |

frame [`oauth.py`](/services/frame) 가 **검증된 레퍼런스**다 (PKCE 단발 code·atomic 단일소비·S256 constant-time·DCR allowlist 전부 구현). Blueprint OP 는 그것을 그대로 따르되 **차이점 3 가지**:
1. HS256 대신 **RS256 서명** + JWKS 공개 (서비스가 secret 공유 없이 검증).
2. Microsoft 서버-투-서버 교환 단계 **제거** → 기존 **NextAuth 세션** 재사용.
3. **refresh token** 추가 (CLI UX "로그인 1회 후 한동안 유지" + 취소 지점).

## 플랫폼 JWT (access token) shape

RS256, `kid` 헤더. 클레임:

```jsonc
{
  "iss": "https://blueprint.axellc.com",   // BLUEPRINT_OIDC_ISSUER (per-deployment)
  "sub": "<Entra oid>",                     // User.entraOid — 안정적 cross-app 식별자
  "email": "ai@axellc.com",                 // 소문자 정규화
  "aud": "https://axe.axelabs.ai",          // 플랫폼 audience (단일; 전 서비스 공통)
  "scope": "openid profile email frame hive index cortex matrix",
  "azp": "axe-cli",                         // 토큰을 받은 client_id (감사)
  "jti": "<uuid>",                          // 감사 + (선택) 취소 denylist
  "iat": 1730000000,
  "exp": 1730003600,                        // 1h
  "ent": { "axec": ["read","write"], "axev": ["read"] }  // Phase 3 (선택) — 현재는 서비스가 무시
}
```

- **access token**: 짧음 (1h). 만료 시 refresh 로 무중단 갱신.
- **refresh token**: opaque 랜덤 (JWT 아님), 서버측 저장(Prisma), **회전**(rotating — 사용 시 새 발급+구 폐기), 30d. → 취소·도난탐지 지점.
- **scope**: Phase 1 = 서비스 단위 grant (존재 = 그 서비스 접근). frame 의 entity별 read/write 인가는 customers.yaml email→entity 가 그대로 담당. (`frame:read` 같은 fine scope + `ent` 클레임 인가중앙화 = Phase 3.)
- **aud 단일값의 트레이드오프**: 한 토큰이 전 서비스 → 토큰 유출 시 blast radius 가 플랫폼 전체. 완화 = 짧은 exp(1h) + scope + refresh 취소 + loopback-only public client + PKCE 필수. (per-service aud 격리보다 편의를 택한 D-axe-idp-1 의 의도적 선택.)

## 서비스별 신뢰 이전 (trust migration)

각 서비스는 **feature flag** 로 Blueprint 신뢰를 켠다 — `BLUEPRINT_ISSUER` + `BLUEPRINT_JWKS_URL` (+ `BLUEPRINT_AUDIENCE`) **unset = 현행 동작 그대로** (비파괴·롤백 = env 제거).

### frame · hive (Python — 낮음, Phase 1·2)

`http_server.py` 의 dispatch 에 분기 한 줄 + verifier 모듈 신설:

```python
# http_server.py — 기존 if _is_microsoft_iss(iss): ... else: (HS256) 사이에 삽입
elif _is_blueprint_iss(iss):                       # iss == BLUEPRINT_ISSUER
    payload = await verify_blueprint_token(         # 신규 auth_blueprint.py
        agent_token, jwks_url=BLUEPRINT_JWKS_URL,
        issuer=BLUEPRINT_ISSUER, audience=BLUEPRINT_AUDIENCE,
    )
    email = extract_email(payload)
    agent_claims = resolve_subject_to_claims(payload, email, customer_id=cid)  # 기존 재사용
```

`verify_blueprint_token` = `auth_oidc.py` 의 JWKS fetch/cache(`createRemoteJWKSet` 대응, 1h TTL·kid-miss 강제갱신)를 그대로 미러, issuer/aud/exp 검증 후 payload 반환. email→entity 는 **기존 `resolve_subject_to_claims` 무수정 재사용** → 인가 모델 불변.

### cortex · index (Rust — 중간, Phase 2)

`auth.rs:verify_token` 의 단일 MS issuer hardcode 를 issuer-dispatch 로 리팩터:

```rust
let iss = peek_iss(token)?;                        // unverified payload iss
match trusted_issuer(&iss, &state.settings) {
    Issuer::Microsoft => verify_rs256(token, ms_jwks, ms_iss, ms_aud).await,
    Issuer::Blueprint => verify_rs256(token, bp_jwks, bp_iss, bp_aud).await,  // 신규
    Issuer::Self_     => verify_hs256(token, secret),   // cortex 기존 / index 는 여기서 배선
    _ => Err(Unknown),
}
```

`verify_rs256(token, jwks, iss, aud)` 헬퍼로 추출하면 MS·Blueprint 가 같은 코드 재사용. index 는 이참에 `INDEX_JWT_SECRET` HS256 도 배선(cortex `oauth_as.rs` 패턴 복사).

### matrix (Rust custom — 높음, Phase 2 후)

HS256 단일·동기 미들웨어 → ① async 화 ② iss peek ③ Blueprint RS256 분기 ④ JWKS fetch(reqwest 이미 보유) ⑤ `Claims` 에 `iss`/`aud` 필드 추가. ~80–120 LOC. seam 없음 → 마지막.

| 서비스 | flag env | 작업량 | Phase |
|---|---|---|---|
| frame | `BLUEPRINT_ISSUER`,`BLUEPRINT_JWKS_URL`,`BLUEPRINT_AUDIENCE` | ~80 LOC + tests | **1** (모델 증명) |
| hive | 동일 | ~60 LOC (frame 복사) | 2 |
| cortex | `BLUEPRINT_*` (settings) | refactor + ~80 LOC | 2 |
| index | `BLUEPRINT_*` + `INDEX_JWT_SECRET` 배선 | refactor + ~100 LOC | 2 |
| matrix | `MATRIX_BLUEPRINT_*` | async refactor ~120 LOC | 2 |
| blueprint (자체 MCP) | `BLUEPRINT_ISSUER`,`BLUEPRINT_AUDIENCE` (JWKS 파생) | ~150 LOC + 10 tests (frame 미러) | **완료 (2026-06-04)** |

> 위 표가 frame·hive·cortex·index·matrix 만 세고 **Blueprint 자체 MCP 를 빠뜨린 것**이 2026-06-04 의 401 버그 근원이었다 — issuer 가 곧 resource server 라는 사실이 자명해 보여 목록에서 누락됐다. 새 OP 를 세울 땐 *그 OP 의 MCP 도* 이 표의 한 행이다.

## `axe login` — loopback PKCE (CLI)

[AXE CLI](/services) 에 `axe login` (인자 없음) 추가. stdlib 만 (`http.server`·`webbrowser`·`hashlib`·`secrets`·`urllib`):

```
1. CLI: 랜덤 loopback 서버 기동 http://127.0.0.1:<port>/callback
2. CLI: POST /oauth/register {redirect_uris:[loopback]}  → client_id  (또는 정적 axe-cli)
3. CLI: code_verifier 생성, challenge=S256, state 생성 → 브라우저 open
        /oauth/authorize?response_type=code&client_id=…&redirect_uri=loopback
                        &code_challenge=…&code_challenge_method=S256&scope=…&state=…
4. 브라우저: (NextAuth 세션 없으면) Entra SSO 1회 → Blueprint 가 loopback 으로 302 ?code=…&state=…
5. CLI(loopback): code 수신, state 검증 → POST /oauth/token
        grant_type=authorization_code&code=…&code_verifier=…&redirect_uri=loopback
6. CLI: {access_token, refresh_token, expires_in} → keychain 저장. 브라우저엔 "닫아도 됨".
```

- **헤드리스(Codex/CI)**: `axe login --token <T>` / `AXE_TOKEN` env 공존 (gh 모델) — 변경 없음. 또는 Phase 3 **device-code**.
- **검증은 사람 브라우저 로그인 필요** → 단독 e2e 불가, 운영자 SSO 1회로 마무리.
- 토큰 자동 refresh: access 만료 시 CLI 가 저장된 refresh 로 `/oauth/token` (`grant_type=refresh_token`) 조용히 갱신.

## 거버넌스 (Blueprint 중앙)

- **scope**: `openid profile email` + 서비스 grant. 발급 시 Blueprint 가 user 의 `entityScopes`/`EntityRole` 로 허용 scope 를 결정 (consent UI 는 first-party CLI 라 Phase 1 생략 가능, 외부 third-party client 도입 시 추가).
- **revoke**: refresh token 서버측 저장 → `/oauth/revoke` 또는 admin UI 에서 폐기. access 는 짧아(1h) revoke latency = exp. 더 강한 즉시취소 필요 시 `jti` denylist (introspection 없이).
- **audit**: 모든 발행(누가·언제·client·scope·jti)을 append-only 로그. Blueprint 의 기존 로깅/DB 재사용.
- **per-tenant**: issuer 가 per-deployment 이므로 고객사 토큰은 그 고객 Blueprint 가 발행·관리 (sovereignty 정합).

## 키 관리

- RS256 keypair. private key = **vault** (`blueprint/axe/oidc-signing-key`, PKCS8 PEM 또는 JWK) → `BLUEPRINT_OIDC_PRIVATE_KEY` 로 주입. 공개키만 `/.well-known/jwks.json` 에 `kid` 와 함께.
- **회전**: JWKS 에 2 키(old+new) 동시 게시 → 신규 서명은 new `kid`, 검증은 둘 다 수용 → old 만료 후 제거. 서비스 JWKS 캐시 TTL(1h) 고려.
- `jose` (`generateKeyPair`/`importPKCS8`/`SignJWT`/`exportJWK`) 사용 — 이미 dependency.

## 데이터 모델 (Prisma 신설)

```prisma
model OAuthAuthCode {       // authorize→token 단발 code (frame oauth_authorization_codes 대응)
  code         String   @id
  clientId     String
  userId       String
  codeChallenge String
  redirectUri  String
  scope        String
  expiresAt    DateTime
  consumedAt   DateTime?
}
model OAuthRefreshToken {   // refresh + revoke + 회전
  id         String   @id @default(cuid())
  tokenHash  String   @unique     // 평문 저장 안 함
  userId     String
  clientId   String
  scope      String
  expiresAt  DateTime
  revokedAt  DateTime?
  rotatedTo  String?            // 회전 추적 (도난 재사용 탐지)
  createdAt  DateTime @default(now())
}
model OAuthClient {         // 클라이언트 레지스트리 (axe-cli 정적 seed + DCR)
  clientId     String   @id
  name         String
  redirectUris String              // JSON 배열
  type         String   @default("public")  // PKCE-only
  createdAt    DateTime @default(now())
}
```

## 비파괴 cutover (신·구 병행)

보안 핵심 → **점진·가역**. 어느 단계도 기존 claude.ai MS-OAuth 흐름과 frame/cortex 프록시를 **깨지 않는다**.

1. **Blueprint OP 추가** = 전부 신규 route. 기존 동작 0 변경.
2. **서비스별 Blueprint 신뢰 추가** = iss 분기 1개 ADD. MS·HS256 경로 불변. flag(`BLUEPRINT_ISSUER`) unset 이면 무시 = 현행. → **서비스 단위로 독립 ship·롤백**.
3. **per-service 프록시 폐기는 나중** — frame/cortex 의 claude.ai DCR 프록시(D-ops-14/15)는 Blueprint OP 가 충분히 증명될 때까지 **공존**. claude.ai 커넥터 흐름 내내 유지. 통합(claude.ai 를 Blueprint OP 로 이전 + 프록시 제거)은 Phase 2 말~3.

롤백 = 해당 서비스 `BLUEPRINT_*` env 제거 후 recreate. 토큰 발행 중단 = Blueprint route 비활성.

## Phase 계획

- **Phase 1 (모델 증명) ✅ 2026-06-04**: Blueprint OP(discovery·jwks·authorize·token·register·revoke) + RS256 키(vault) + Prisma 모델 + `axe-cli` 정적 client + `axe login` loopback PKCE + **frame** Blueprint 신뢰. → 운영자 브라우저 SSO 1회로 `axe login` → `axe frame tools` GREEN 증명 완료.
- **Phase 2 ✅ 2026-06-04**: hive·cortex·index·matrix trust 이전 — 5개 서비스 전부 LIVE + 영속(`BLUEPRINT_ISSUER` compose/.env.local 기본값) + e2e(`axe <svc> tools`). claude.ai → Blueprint OP 이전 + frame/cortex 프록시 폐기는 미평가(잔여).
- **Phase 3**: 인가 중앙화(`ent` 클레임 + fine scope, 서비스가 customers.yaml 대신 토큰 grant 사용) + 감사 UI + headless device-code(RFC 8628, `B-axe-cli-device-code` — 미구현) + 키 회전 자동화.

## 미해결 / 결정 필요

- **issuer 도메인**: 현재 `blueprint.axellc.com` (NEXTAUTH_URL). axelabs.ai 도메인 이전 시 issuer 변경 = 발행된 토큰·서비스 신뢰설정 일괄 영향 → 이전 **전에** 확정하거나 `auth.axelabs.ai` 안정 alias 고정.
- **claude.ai 통합 시점**: claude.ai 커넥터를 Blueprint OP 뒤로 옮기면 per-service 프록시 폐기 가능. 단 claude.ai DCR redirect allowlist·Mcp-Session-Id 흐름 재검증 필요 (B-axe-cli 의 public 멀티스텝 403 함정과 연동).
- **인가 중앙화 범위**: entity grant 를 토큰에 심는 순간 customers.yaml 의 email→entity 가 Blueprint 로 이동 → 큰 마이그레이션. Phase 1 은 인증만, 인가는 그대로 두는 게 안전.

## 관련

- 결정: [D-axe-idp-1](/ops/decisions) (본 설계) · [D-axe-cli-1](/ops/decisions) (CLI·토큰모델) · [D-ops-14/15](/ops/decisions) (per-service 프록시 — 통합 대상)
- 현행 인증: [/architecture/auth](/architecture/auth) (3 경로 — Blueprint = 4번째 trusted issuer 로 합류)
- 레퍼런스 구현: frame `src/frame/mcp/oauth.py` (검증된 OAuth 2.1 AS + PKCE + DCR)
- 백로그: [B-axe-idp-1](/ops/backlog)
