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

---
title: 인증 · 권한
description: Microsoft Entra ID OAuth 2.0, frame JWT, dual-token model.
---

# 인증 · 권한

## 3 가지 인증 경로

| 경로 | 사용 주체 | 토큰 | 검증 |
|---|---|---|---|
| **A. Microsoft Entra ID OAuth** | 직원 (Claude Code / claude.ai) | access_token (RS256, Microsoft 서명) | frame middleware: JWKS RS256 + aud match |
| **B. frame HS256 JWT** | 운영자 / 서비스 토큰 (cron job 등) | HS256 (FRAME_JWT_SECRET 으로 서명) | frame middleware: `audience=client_id` |
| **C. dual-token** | Blueprint agent + per-user 위임 | (agent_jwt, user_jwt) | intersection of permissions |

`iss` claim 으로 자동 dispatch — `https://login.microsoftonline.com/...` 시작이면 A, 아니면 B.

> **경로 D — Blueprint 플랫폼 토큰 (D-axe-idp-1, Phase 1+2 LIVE 2026-06-04)**: **Blueprint = 플랫폼 OIDC Provider** 가 4번째 신뢰 발행자로 합류 — 로그인 1회로 전 서비스. Blueprint 가 Entra 를 federate(기존 NextAuth 세션 재사용) + RS256 플랫폼 토큰 발행(`/oauth/*` + `/.well-known/{openid-configuration,jwks.json}` on `blueprint.axellc.com`). 각 서비스는 `iss=blueprint` 분기에서 Blueprint JWKS 로 RS256 검증(`aud=https://axe.axelabs.ai`), email→entity 는 기존 `customers.yaml` 그대로. **frame·hive·cortex·index·matrix + Blueprint 자체 MCP — 6개 서비스 전부 Blueprint 토큰 신뢰** (Python: `auth_blueprint.py` + `is_blueprint_iss` dispatch / Rust: `peek_iss`→`verify_rs256` vs Blueprint JWKS). 영속 설정(compose/`.env.local` 의 `BLUEPRINT_ISSUER` 기본값)·e2e 검증(`axe login` → `axe <svc> tools` GREEN). 비파괴 롤백 = 서비스 `BLUEPRINT_*` env 제거(unset=종전 Microsoft 경로). 설계·현행 SSOT: [/architecture/platform-identity](/architecture/platform-identity).

## 경로 A — Microsoft Entra ID (직원)

각 customer 의 Microsoft Entra ID tenant 에 **5 개의 app** 이 등록되어 있어야 합니다 (axe customer 2026-05-21 현재):

| App 이름 | 용도 | 토큰 종류 | 비고 |
|---|---|---|---|
| Frame MCP | 회계 backend 접근 | access_token (aud=client_id, v2) | public client + PKCE (`b7ead15d-...` Hive 패턴) |
| Hive MCP | HR backend 접근 | access_token (aud=client_id, v2) | public client + PKCE, `client_id=b7ead15d-2fea-4864-a5a8-b4b07d1629d4` |
| Blueprint MCP | platform MCP at apex | access_token (aud=URI, v1) | public client + PKCE, `/api/mcp` apex |
| Vaultwarden | password vault SSO | OIDC id_token | confidential, secret 보관 |
| Blueprint Web | web UI 로그인 | OIDC id_token | NextAuth |

자동 등록: `az ad app create` + `az rest --method PATCH /applications/APP_ID` (identifierUris + requestedAccessTokenVersion=2 + oauth2PermissionScopes.value="mcp.access"). 운영자 손작업 = `axe.axelabs.ai` 도메인 검증 1회만.

### Frame MCP app 설정 (현재 axe live)

```yaml
client_id:           137fc0ef-eb9f-4903-acbc-1a748add349c
application_id_uri:  https://axe.axelabs.ai/frame/mcp    # 도메인 검증 필요
tenant_id:           122fb574-7efa-476a-95b6-bee81bce2cce
platform:            Web
redirect_uris:
  - https://claude.ai/api/mcp/auth_callback
  - https://claude.com/api/mcp/auth_callback
scopes:
  - openid
  - profile
  - email
  - https://axe.axelabs.ai/frame/mcp/mcp.access
allow_public_client_flows: true   # PKCE 활성 (manifest `isFallbackPublicClient: true`)
# client_secret 발급 여부:
#   - 직접-Microsoft path (현재 live):     발급 X (PKCE-only, claude.ai 가 secret 안 보냄)
#   - OAuth proxy path (D-ops-15 dormant): 발급 + frame .env 에 보관
# access_token_version (manifest `requestedAccessTokenVersion`):
#   - null/1 → access_token aud = Application ID URI (https://...)
#   - 2      → access_token aud = client_id GUID
# frame middleware 는 양쪽 모두 수용 (audiences=[client_id, app_id_uri]).
```

### Azure App ID URI

Microsoft v2 endpoint 의 **resource indicator** 와 **scope URI prefix** 가 정확히 일치해야 Microsoft 가 token 발급. 발견된 함정:

- **AADSTS9010010**: scope=`api://137fc0ef-.../mcp.access` + resource=`https://axe.axelabs.ai/frame/mcp` → mismatch.
- 해결: domain verify (`axe.axelabs.ai` 에 TXT `MS=ms...` 등록) → Application ID URI 를 `https://axe.axelabs.ai/frame/mcp` (URL 형식) 로 변경 → scope 도 같은 prefix.

## frame OAuth-RP middleware

frame 서버의 `auth_oidc.py` 가 다음을 수행:

1. `Authorization: Bearer &lt;access_token&gt;` 헤더 추출
2. `iss` claim peek (서명 검증 전 unverified decode) → Microsoft 시작이면 A 경로
3. Microsoft JWKS fetch (1h TTL, kid miss 시 강제 갱신)
4. RS256 서명 검증
5. **audience 검증**: token `aud` 가 `{client_id GUID, Application ID URI}` 중 하나와 일치 (v1/v2 호환)
6. issuer 검증: `https://login.microsoftonline.com/{tenant_id}/v2.0`
7. `email` / `preferred_username` / `upn` 중 첫 비어 있지 않은 값 → email
8. `customers.yaml > user_entity_map[email]` → entity 권한 dict 매핑
9. `TokenClaims` 생성 → ContextVar 주입 → tool 호출에 사용

### RFC 9728 protected-resource metadata path

claude.ai Connector 와 다른 MCP client 가 OAuth challenge 시작 시 fetch 하는 metadata endpoint 는 **두 path** 에서 동일한 응답:

| Path | 용도 |
|---|---|
| `/frame/.well-known/oauth-protected-resource` | server-level (RFC 8615 base URL convention) |
| `/frame/mcp/.well-known/oauth-protected-resource` | resource-level (RFC 9728 — application_id_uri 의 sub-path). claude.ai Connector / Anthropic MCP client 가 이 path 를 fetch |

양쪽 모두 `_PUBLIC_PATHS` 로 unauthenticated 면제 + `inner.router` 에 같은 handler 마운트. 401 응답의 `WWW-Authenticate: Bearer realm="frame", resource_metadata="..."` 가 가리키는 URL 도 양쪽 모두 valid (D-ops-23, 2026-05-22 — 이전엔 resource-level path 401 → claude.ai Reconnect silently 실패).

### Schema discovery — auth-required

`/frame/schemas` (그리고 hive 의 `/hive/schemas`) 는 RFC 9728 metadata 와 달리 **auth-required** — `_PUBLIC_PATHS` 미포함. JWTAuthMiddleware 가 일반 MCP 호출과 동일 검증 적용. Blueprint artifact + PARA 지식 레이어 ([D-bp-artifact-1](/ops/decisions)) 가 fetch 시 자기 MCP 토큰 재사용. 자세한 endpoint contract = [services/frame § Schema discovery](/services/frame#schema-discovery--get-frameschemas).

## 권한 모델 — Scope

```
read  < write < close < admin
```

- `read` — query_balance, list_journals
- `write` — post_journal, flag_uncertainty
- `close` — soft_close period, hard_close (운영자)
- `reopen` — 폐쇄된 period 재오픈 (드물게)
- `admin` — chart of accounts 변경, policy 마이그레이션

권한은 **entity 별로** 부여 (예: `{"axec": ["read", "write"], "axev": ["read"]}`).

## dual-token (CFO + accountant 패턴)

CFO 에이전트가 회계사 A 를 대신해 활동할 때:

```
Authorization: Bearer &lt;CFO agent JWT&gt;         ← 광범위한 권한
X-User-Token:   &lt;accountant A JWT&gt;            ← axec 만, read+write
```

frame 의 `dual_authorize(agent, user, entity, scope)` 가 두 토큰의 권한 교집합으로 게이팅:

```python
effective_scopes = agent.permissions["axec"] ∩ user.permissions["axec"]
```

최소 권한 원칙 강제. agent 가 admin 이어도 user 가 read-only 면 read 만 가능.

## 운영자 자체 토큰

운영자 (`ai@axellc.com`) 가 CLI 에서 직접 호출 시:

```bash
docker exec frame-mcp-blue python -m frame.cli mcp-token \
    --sub ai@axellc.com \
    --customer axe \
    --entity axec:read,write,close \
    --entity axev:read,write,close \
    --ttl 2592000      # 30 days
```

→ HS256 frame JWT 발급. 경로 B 로 검증.

운영자 토큰은 **Vaultwarden 의 `axe-frame-jwt` collection** 에 보관 (entity 별 vault).

## 함정

| 함정 | 결과 | 회피 |
|---|---|---|
| Application ID URI 와 scope prefix 불일치 | AADSTS9010010 | 두 값 같은 URL prefix |
| Allow public client flows: No + secret 없음 | AADSTS7000218 | Yes 토글 OR secret 입력 |
| accessTokenAcceptedVersion=2 + aud check on URL | mcp_client_invalid | **v2 강제 + middleware multi-issuer 양쪽 모두**: (1) Azure app 등록 시 `requestedAccessTokenVersion=2` 강제 (bootstrap.sh manifest PATCH 시점), (2) middleware 가 v1+v2 issuer 양쪽 수용 (defense in depth — v1 token 이 어떤 사유로 들어와도 graceful 처리). 단독 (1) = bootstrap PATCH 누락 시 사고. 단독 (2) = client 측 v1 token 발급 자유 잔존. |
| App ID URI 삭제 (cleanup 잘못) | AADSTS500011 resource principal not found | 복원 후 propagation 5-10분 대기 |
| 잘못된 client_id 입력 (vaultwarden vs frame_mcp) | AADSTS700016 application not found | customers.yaml 확인 |
| `az ad app create` 후 sign-in 시도 → 즉시 실패 | Entra trace ID + "Authorization with the MCP server failed". `az ad app create` 는 **application object 만 생성, Service Principal 자동 생성 X** | `az ad sp create --id <appId>` 별도 호출 |
| SP 있는데 sign-in 시 같은 메시지 | Service Principal 존재해도 `requiredResourceAccess` 비어 있고 admin consent 안 받음 | manifest 에 `mcp.access` (self) + Graph `User.Read` (e1fe6dd8-ba31-4d61-89e7-88639da4683d) `requiredResourceAccess` 추가 후 admin consent grant |
| `az ad app permission admin-consent` → 403 Forbidden | 명령 실행 user 가 tenant admin role 없음 ("This operation can only be performed by an administrator") | (a) Portal UI: App registrations → 해당 app → API permissions → "Grant admin consent for {`{tenant}`}" 버튼, (b) admin user 로 `az login` 후 명령 재실행 |
| `az ad app credential reset --append` parsing 시 stderr 섞임 | JSON 출력에 progress 메시지 혼합 → `python3 -c "json.load"` 실패 | `--query 'password' -o tsv` 로 단일 필드 추출 |
| Vaultwarden SSO with Microsoft Entra ID → "Your provider does not send email verification status" | Entra ID id_token 에 `email_verified` claim 없음, Timshel fork 가 거부 | `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION: "true"` 추가 (D-ops-24, 2026-05-22) |
| Blueprint OAuth scope 추가 (`SCOPES` in `src/lib/graph.ts`) ship 후 admin consent grant 누락 | (1) 모든 user 의 GraphToken refresh 침묵 실패 — UI 상 "Connected" 표시 유지하나 실 호출 시 401/403, (2) 새 user OAuth flow 차단 `AADSTS65001 Consent_VersionMismatch`, (3) `acquireTokenByClientCredential` 캐시된 옛 토큰 (insufficient-scope) 계속 반환 | (1) ship 직후 `az ad app permission admin-consent --id <client_id>` 실행, (2) `docker restart blueprint-app-green` (또는 -blue) — MSAL in-memory 캐시 폐기, (3) 자동화 후보: `axe ship blueprint` post-deploy hook + Blueprint daily token health check ([B-blueprint-scope-change-admin-consent-runbook](/ops/backlog) 참조). 절차 상세 = 아래 [OAuth scope 추가 시 운영자 절차](#oauth-scope-추가-시-운영자-절차) |
| Frame/Hive MCP middleware 의 `_MICROSOFT_ISS_PREFIX` 가 v2 issuer prefix (`https://login.microsoftonline.com/`) 만 매치 (trap #33, Truvia 2026-05-25 보고) | Microsoft v1 access_token (iss=`https://sts.windows.net/<tenant>/`) 보내면 middleware 가 Microsoft 경로 미인식 → HS256 fallback path 진입 → `algorithms=["RS256"]` allowlist 가 RS256 token 을 잘못된 path 에서 reject → `The specified alg value is not allowed` | (1) Azure app 등록 시 `requestedAccessTokenVersion=2` 강제 (bootstrap.sh + 등록 후 manifest 검증 — Truvia 우회 = 3 MCP app 모두 manifest PATCH), (2) middleware 가 v1+v2 issuer 양쪽 수용 (Blueprint MCP 의 `config.py:76,81` + `auth_oidc.py:153` 패턴 mirror). 위치: `/Users/axe/frame/src/frame/mcp/http_server.py:141` + `/Users/axe/hive/src/hive/mcp/http_server.py:67`. 영구 fix = [B-trap-33-frame-hive-multi-issuer](/ops/backlog) (code portion 잔존, docs portion ✅ 2026-05-28) |

상세 troubleshooting: [/onboard/troubleshooting](/onboard/troubleshooting).

## OAuth scope 추가 시 운영자 절차

Blueprint Next.js app (`2b222356-1c36-48e0-96a3-2c5e0ecbf937`) 의 `SCOPES` array (`src/lib/graph.ts`) 변경 ship 시 다음 순서를 그대로 따를 것. 누락 시 위 함정 표 마지막 row 의 3 가지 증상 발현 (2026-05-26 D-bp-mcp-calendar-1 ship 후 발현 사례 있음).

```bash
# 1. SCOPES array 변경 commit + ship
cd /Users/axe/blueprint && axe ship

# 2. admin consent grant (운영자 = Global Admin 만 가능, soohun.kang)
az login --allow-no-subscriptions
az ad app permission admin-consent --id <client_id>
# (client_id = Blueprint Next.js app, 예: 2b222356-1c36-48e0-96a3-2c5e0ecbf937)

# 3. blueprint-app 재시작 (MSAL in-memory 캐시 폐기)
ssh axe-macmini "cd /Users/axe/blueprint && docker compose restart blueprint-app-blue blueprint-app-green"

# 4. 검증 (token health)
# - /api/admin/graph-tokens (또는 /settings) 에서 각 user 의 expiresAt 갱신 확인
# - 본인 계정으로 Graph API 호출 1회 (e.g. /api/admin/broadcast-dm test)

# 5. user 측 reconnect 안내 (Teams DM)
# - admin consent 후에도 옛 refresh token 의 grant 가 새 scope 안 가짐
# - 각 user 가 /api/graph/auth 재방문 → 새 scope 포함 consent → 새 refresh token 발급
```

본 절차 가운데 step 3 (MSAL in-memory cache 폐기) 의 근본 원인은 [/ops/known-gaps#msal-acquiretokenbyclientcredential-토큰-캐시](/ops/known-gaps) 참조. Blueprint 의 Azure App 2 개 (Next.js app `2b222356-...` vs MCP custom connector `482598f7-540c-462c-9dfd-b957651eb804`) 구분도 같은 페이지에 있음 — 본 SCOPES 변경 및 admin consent 는 **Next.js app 측에만** 적용.

## app-only Application permission (send-as)

위 [OAuth scope 추가 시 운영자 절차](#oauth-scope-추가-시-운영자-절차) 는 **delegated** scope (`SCOPES` in `src/lib/graph.ts`, caller 본인 토큰) 변경용이다. 그와 별개로, **admin send-as** 기능 (caller `role=admin` 이 타 사용자 리소스에 write) 은 app-only token (`getAppOnlyClient()` = `acquireTokenByClientCredential`) 을 쓰며, **Application permission** + tenant admin consent 를 요구한다. delegated 와 달리 사용자별 re-consent 가 아니라 테넌트 1회 admin consent 다.

대상 App = **Blueprint Next.js app (`2b222356-1c36-48e0-96a3-2c5e0ecbf937`)** — MCP connector app (`482598f7-...`) 이 아니다 (함정 [/ops/known-gaps#blueprint-azure-app-id-혼동](/ops/known-gaps#blueprint-azure-app-id-혼동)).

| 기능 | Application permission | delegated 대응 (이미 SCOPES) | 결정 |
|---|---|---|---|
| calendar send-as (`create_event` 등 `as_user_email`) | `Calendars.ReadWrite` | `Calendars.ReadWrite` | D-bp-mcp-calendar-2 |
| `send_mail` send-as (`as_user_email`) | `Mail.Send` | `Mail.Send` (→ self 발송은 추가 consent 불필요) | D-bp-mcp-mail-1 |

```bash
# 1. permission 추가: Azure Portal → App registrations → Blueprint (Next.js)
#    → API permissions → Add → Microsoft Graph → Application permissions
#    → Mail.Send (well-known roleId b633e1c5-b582-4048-a93e-9f11b44c7e96)
# 2. tenant admin consent (Global Admin 계정 — AXE 테넌트는 soohun.kang 만)
az ad app permission admin-consent --id 2b222356-1c36-48e0-96a3-2c5e0ecbf937
# 3. MSAL client-credential in-memory 캐시 폐기 (옛 insufficient-scope 토큰 재사용 방지)
docker restart blueprint-app-blue   # 또는 -green
```

consent 누락 시 send-as 호출은 Graph 403 → 코드가 `mail_send_not_consented` (mail) / `app_only_token_unavailable` (calendar) 로 surface. self (delegated) 경로는 본 permission 없이도 동작하므로 **send-as 만** 영향.
