Add complete Korean-language technical documentation analyzing Claude Code CLI internals, organized in a leveled guide structure (Level 1-3 + Appendix). Level 1 (입문): architecture overview, request lifecycle, key concepts Level 2 (시스템): QueryEngine, Tool system, Command system, Permission system, Agent coordinator, UI/Ink components Level 3 (심화): MCP/LSP integration, IDE bridge, Plugin/Skill system, Context compression, OAuth/Auth, Telemetry Appendix: Korean-English glossary, file map, design patterns Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
392 lines
16 KiB
Markdown
392 lines
16 KiB
Markdown
# 인증 흐름 분석: OAuth 2.0, macOS Keychain, JWT 검증
|
|
|
|
> **레벨**: 내부 구현 (Level 3)
|
|
> **대상 독자**: Claude Code 핵심 기여자, 인증 통합 개발자
|
|
> **관련 소스**: `src/services/oauth/`
|
|
|
|
---
|
|
|
|
## 개요
|
|
|
|
Claude Code의 인증 시스템은 OAuth 2.0 Authorization Code Flow with PKCE(Proof Key for Code Exchange)를 기반으로 구현되어 있다. 단순한 OAuth 클라이언트 구현을 넘어, 두 가지 인증 경로(자동/수동), 토큰 갱신 최적화, 프로필 캐싱, 그리고 비-브라우저 환경 지원을 통합한다.
|
|
|
|
핵심 클래스인 `OAuthService`는 상태 기계(state machine)처럼 동작한다: `codeVerifier`와 `authCodeListener`를 인스턴스 내부에 캡슐화하여, 플로우의 각 단계가 올바른 순서로 실행되도록 보장한다. 외부 저장소(macOS Keychain)와의 연동은 `src/utils/auth.ts`의 상위 계층에서 처리된다.
|
|
|
|
---
|
|
|
|
## 아키텍처 다이어그램
|
|
|
|
### OAuth 2.0 PKCE 플로우
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant User as 사용자
|
|
participant CLI as OAuthService
|
|
participant ACL as AuthCodeListener\n(localhost HTTP 서버)
|
|
participant Browser as 브라우저
|
|
participant Auth as Anthropic 인증 서버
|
|
participant Token as Token 엔드포인트
|
|
participant Profile as Profile API
|
|
|
|
CLI->>CLI: generateCodeVerifier() — 32바이트 무작위
|
|
CLI->>CLI: generateCodeChallenge() — SHA-256(verifier) → base64url
|
|
CLI->>CLI: generateState() — 32바이트 무작위 (CSRF 방지)
|
|
CLI->>ACL: authCodeListener.start() — OS 할당 포트
|
|
ACL-->>CLI: port 번호
|
|
|
|
CLI->>Browser: openBrowser(automaticFlowUrl)\n→ ?client_id=...&code_challenge=...&state=...
|
|
CLI->>User: manualFlowUrl 표시 (수동 대안)
|
|
|
|
Browser->>Auth: GET /authorize?...
|
|
Auth->>User: 로그인 페이지
|
|
User->>Auth: 자격증명 입력
|
|
Auth->>Browser: 302 → http://localhost:{port}/callback?code=AUTH_CODE&state=STATE
|
|
Browser->>ACL: GET /callback?code=AUTH_CODE&state=STATE
|
|
|
|
ACL->>ACL: state 검증 (CSRF 방지)
|
|
ACL-->>CLI: authorizationCode (Promise resolve)
|
|
|
|
CLI->>Token: POST /oauth/token\n{grant_type: authorization_code, code, code_verifier, ...}
|
|
Token-->>CLI: {access_token, refresh_token, expires_in, scope}
|
|
CLI->>Profile: GET /api/oauth/profile\n(Authorization: Bearer access_token)
|
|
Profile-->>CLI: {organization_type, rate_limit_tier, ...}
|
|
|
|
ACL->>Browser: 302 → CLAUDEAI_SUCCESS_URL (성공 리다이렉트)
|
|
CLI-->>User: OAuthTokens 반환
|
|
```
|
|
|
|
### 인증 컴포넌트 의존 관계
|
|
|
|
```mermaid
|
|
graph TB
|
|
subgraph "OAuth 서비스 계층"
|
|
OS["OAuthService\n(index.ts)"]
|
|
ACL["AuthCodeListener\n(auth-code-listener.ts)"]
|
|
CL["OAuth Client\n(client.ts)"]
|
|
CR["Crypto Utils\n(crypto.ts)"]
|
|
GP["getOauthProfile\n(getOauthProfile.ts)"]
|
|
end
|
|
|
|
subgraph "설정"
|
|
OC["getOauthConfig()\n(constants/oauth.ts)"]
|
|
AC["ALL_OAUTH_SCOPES\nCLAUDE_AI_INFERENCE_SCOPE"]
|
|
end
|
|
|
|
subgraph "저장소"
|
|
GC["getGlobalConfig()\n전역 설정 (JSON 파일)"]
|
|
KCH["macOS Keychain\n(utils/auth.ts)"]
|
|
SS["Secure Storage\nOAuthTokens 영속화"]
|
|
end
|
|
|
|
subgraph "토큰 갱신"
|
|
CAN["checkAndRefreshOAuthTokenIfNeeded()\n(utils/auth.ts)"]
|
|
ROAT["refreshOAuthToken()\n(client.ts)"]
|
|
end
|
|
|
|
OS --> ACL
|
|
OS --> CL
|
|
OS --> CR
|
|
CL --> GP
|
|
CL --> OC
|
|
CL --> AC
|
|
OS --> KCH
|
|
ROAT --> GC
|
|
ROAT --> SS
|
|
CAN --> ROAT
|
|
|
|
style OS fill:#2d6a9f,color:#fff
|
|
style ACL fill:#1e7a4f,color:#fff
|
|
style KCH fill:#7a2020,color:#fff
|
|
```
|
|
|
|
---
|
|
|
|
## 핵심 구현 분석
|
|
|
|
### 1. PKCE 암호 기법 (`crypto.ts`)
|
|
|
|
PKCE는 인증 코드 탈취 공격을 방지하기 위한 RFC 7636 표준 확장이다.
|
|
|
|
```typescript
|
|
// 코드 검증자: 32바이트 무작위 → base64url
|
|
export function generateCodeVerifier(): string {
|
|
return base64URLEncode(randomBytes(32)) // Node.js crypto 모듈
|
|
}
|
|
|
|
// 코드 챌린지: SHA-256(verifier) → base64url
|
|
export function generateCodeChallenge(verifier: string): string {
|
|
const hash = createHash('sha256')
|
|
hash.update(verifier)
|
|
return base64URLEncode(hash.digest())
|
|
}
|
|
|
|
// CSRF 방지 상태값: 32바이트 무작위
|
|
export function generateState(): string {
|
|
return base64URLEncode(randomBytes(32))
|
|
}
|
|
|
|
// base64url: 표준 base64에서 +→-, /→_, = 제거
|
|
function base64URLEncode(buffer: Buffer): string {
|
|
return buffer.toString('base64')
|
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
|
}
|
|
```
|
|
|
|
`code_challenge_method: 'S256'`을 사용하여 검증자의 SHA-256 해시를 챌린지로 전송한다. 인증 서버는 토큰 교환 시 수신한 `code_verifier`를 해싱하여 챌린지와 일치하는지 검증한다.
|
|
|
|
### 2. 로컬 콜백 서버 (`auth-code-listener.ts`)
|
|
|
|
`AuthCodeListener`는 OAuth 리다이렉트를 포착하기 위한 임시 HTTP 서버다. OS 할당 포트를 사용하여 포트 충돌을 방지한다.
|
|
|
|
```typescript
|
|
// 포트 0으로 리슨 → OS가 사용 가능한 포트 자동 할당
|
|
this.localServer.listen(port ?? 0, 'localhost', () => {
|
|
const address = this.localServer.address() as AddressInfo
|
|
this.port = address.port
|
|
resolve(this.port)
|
|
})
|
|
```
|
|
|
|
**상태 검증**: 콜백 수신 시 `state` 파라미터를 초기 생성값과 비교하여 CSRF 공격을 방지한다.
|
|
|
|
```typescript
|
|
if (state !== this.expectedState) {
|
|
res.writeHead(400)
|
|
res.end('Invalid state parameter')
|
|
this.reject(new Error('Invalid state parameter'))
|
|
return
|
|
}
|
|
```
|
|
|
|
**지연 리다이렉트 패턴**: 인증 코드 포착 시 브라우저 응답(`ServerResponse`)을 즉시 종료하지 않고 `pendingResponse`에 보관한다. 토큰 교환 성공 후 `handleSuccessRedirect()`에서 최종 리다이렉트를 수행한다. 이 패턴은 사용자가 성공 페이지를 보기 전에 브라우저 연결이 끊기는 경쟁 조건을 방지한다.
|
|
|
|
```typescript
|
|
// 인증 코드 포착 → 응답 보류
|
|
this.pendingResponse = res
|
|
this.resolve(authCode)
|
|
|
|
// 나중에 토큰 교환 성공 후
|
|
handleSuccessRedirect(scopes: string[]): void {
|
|
const successUrl = shouldUseClaudeAIAuth(scopes)
|
|
? getOauthConfig().CLAUDEAI_SUCCESS_URL
|
|
: getOauthConfig().CONSOLE_SUCCESS_URL
|
|
this.pendingResponse.writeHead(302, { Location: successUrl })
|
|
this.pendingResponse.end()
|
|
this.pendingResponse = null
|
|
}
|
|
```
|
|
|
|
### 3. 이중 플로우 지원 (`OAuthService.startOAuthFlow()`)
|
|
|
|
자동 플로우와 수동 플로우가 동시에 준비되어 경쟁한다:
|
|
|
|
```typescript
|
|
// 두 URL을 각각 생성
|
|
const manualFlowUrl = client.buildAuthUrl({ ...opts, isManual: true })
|
|
const automaticFlowUrl = client.buildAuthUrl({ ...opts, isManual: false })
|
|
|
|
// 자동: 브라우저를 자동으로 열기 + 콜백 리스너 대기
|
|
// 수동: 사용자가 URL을 복사해 붙여넣기
|
|
await authURLHandler(manualFlowUrl) // 수동 옵션 표시
|
|
await openBrowser(automaticFlowUrl) // 자동 플로우 시도
|
|
```
|
|
|
|
`skipBrowserOpen` 옵션은 SDK 제어 프로토콜(`claude_authenticate`)에서 사용된다. 이 경우 클라이언트가 표시 화면을 제어하므로 OAuthService가 직접 브라우저를 열지 않는다.
|
|
|
|
어느 플로우가 먼저 완료되든 `waitForAuthorizationCode()`의 Promise가 resolve된다. `isAutomaticFlow`는 `hasPendingResponse()`로 판별하며, 이에 따라 토큰 교환의 `redirect_uri` 파라미터가 결정된다.
|
|
|
|
### 4. URL 빌더 및 스코프 관리 (`client.ts`)
|
|
|
|
```typescript
|
|
export function buildAuthUrl({ codeChallenge, state, port, isManual, ... }): string {
|
|
const authUrlBase = loginWithClaudeAi
|
|
? getOauthConfig().CLAUDE_AI_AUTHORIZE_URL
|
|
: getOauthConfig().CONSOLE_AUTHORIZE_URL
|
|
|
|
authUrl.searchParams.append('code', 'true') // Claude Max 업셀 표시 트리거
|
|
authUrl.searchParams.append('client_id', getOauthConfig().CLIENT_ID)
|
|
authUrl.searchParams.append('response_type', 'code')
|
|
authUrl.searchParams.append(
|
|
'redirect_uri',
|
|
isManual
|
|
? getOauthConfig().MANUAL_REDIRECT_URL // 수동: 고정 URL
|
|
: `http://localhost:${port}/callback`, // 자동: 로컬 콜백
|
|
)
|
|
// inferenceOnly: 장기 추론 전용 토큰 (스코프 최소화)
|
|
const scopesToUse = inferenceOnly ? [CLAUDE_AI_INFERENCE_SCOPE] : ALL_OAUTH_SCOPES
|
|
authUrl.searchParams.append('scope', scopesToUse.join(' '))
|
|
authUrl.searchParams.append('code_challenge', codeChallenge)
|
|
authUrl.searchParams.append('code_challenge_method', 'S256')
|
|
authUrl.searchParams.append('state', state)
|
|
}
|
|
```
|
|
|
|
`CLAUDE_AI_INFERENCE_SCOPE`는 추론 전용 장기 토큰에 사용되는 최소 스코프다. 이 스코프로 발급된 토큰은 프로필 조회 등 다른 기능을 위해 추가 스코프 확장이 필요할 수 있다.
|
|
|
|
### 5. 토큰 교환 (`exchangeCodeForTokens()`)
|
|
|
|
```typescript
|
|
const requestBody = {
|
|
grant_type: 'authorization_code',
|
|
code: authorizationCode,
|
|
redirect_uri: useManualRedirect
|
|
? getOauthConfig().MANUAL_REDIRECT_URL
|
|
: `http://localhost:${port}/callback`,
|
|
client_id: getOauthConfig().CLIENT_ID,
|
|
code_verifier: codeVerifier, // PKCE 검증자
|
|
state,
|
|
...(expiresIn !== undefined && { expires_in: expiresIn }),
|
|
}
|
|
|
|
const response = await axios.post(getOauthConfig().TOKEN_URL, requestBody, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
timeout: 15000, // 15초 타임아웃
|
|
})
|
|
```
|
|
|
|
`redirect_uri`는 인증 요청에서 사용한 값과 정확히 일치해야 한다. 자동/수동 플로우에 따라 이 값이 달라지므로, `isManual` 플래그를 `exchangeCodeForTokens()`에 전달하여 올바른 URI를 사용한다.
|
|
|
|
### 6. 토큰 갱신 최적화 (`refreshOAuthToken()`)
|
|
|
|
갱신 시 불필요한 프로필 API 호출을 회피하는 캐시 활용 로직이 핵심이다:
|
|
|
|
```typescript
|
|
// 이미 캐시된 프로필 정보가 있으면 API 호출 생략
|
|
const haveProfileAlready =
|
|
config.oauthAccount?.billingType !== undefined &&
|
|
config.oauthAccount?.accountCreatedAt !== undefined &&
|
|
config.oauthAccount?.subscriptionCreatedAt !== undefined &&
|
|
existing?.subscriptionType != null &&
|
|
existing?.rateLimitTier != null
|
|
|
|
const profileInfo = haveProfileAlready ? null : await fetchProfileInfo(accessToken)
|
|
```
|
|
|
|
이 최적화는 하루 약 7백만 건의 불필요한 `/api/oauth/profile` 요청을 절감하는 효과가 있다. 단, `CLAUDE_CODE_OAUTH_REFRESH_TOKEN` 재로그인 경로에서는 `installOAuthTokens()`가 `performLogout()`을 실행하여 시큐어 스토리지를 초기화하기 때문에, `existing` 값이 `null`일 수 있다. 이 경우 캐시된 값을 우선 사용(`profileInfo?.subscriptionType ?? existing?.subscriptionType ?? null`)하여 가입 유형 정보 손실을 방지한다.
|
|
|
|
스코프 확장도 지원한다:
|
|
```typescript
|
|
scope: (requestedScopes?.length ? requestedScopes : CLAUDE_AI_OAUTH_SCOPES).join(' ')
|
|
```
|
|
|
|
백엔드의 refresh-token 그랜트는 `ALLOWED_SCOPE_EXPANSIONS`에 정의된 범위 내에서 초기 인증 시 부여된 스코프를 초과하는 스코프 요청을 허용한다.
|
|
|
|
### 7. 프로필 정보 조회 및 구독 유형 분류 (`fetchProfileInfo()`)
|
|
|
|
```typescript
|
|
export async function fetchProfileInfo(accessToken: string) {
|
|
const profile = await getOauthProfileFromOauthToken(accessToken)
|
|
const orgType = profile?.organization?.organization_type
|
|
|
|
// 조직 유형 → 구독 유형 매핑
|
|
switch (orgType) {
|
|
case 'claude_max': subscriptionType = 'max'; break
|
|
case 'claude_pro': subscriptionType = 'pro'; break
|
|
case 'claude_enterprise': subscriptionType = 'enterprise'; break
|
|
case 'claude_team': subscriptionType = 'team'; break
|
|
default: subscriptionType = null; break
|
|
}
|
|
|
|
return {
|
|
subscriptionType,
|
|
rateLimitTier: profile?.organization?.rate_limit_tier ?? null,
|
|
hasExtraUsageEnabled: profile?.organization?.has_extra_usage_enabled ?? null,
|
|
billingType: profile?.organization?.billing_type ?? null,
|
|
displayName: profile?.account?.display_name,
|
|
accountCreatedAt: profile?.account?.created_at,
|
|
subscriptionCreatedAt: profile?.organization?.subscription_created_at,
|
|
rawProfile: profile,
|
|
}
|
|
}
|
|
```
|
|
|
|
`rawProfile`을 포함하여 반환하는 이유는 상위 계층에서 추가 필드에 접근할 수 있도록 하기 위함이다.
|
|
|
|
### 8. 토큰 만료 검사 (`isOAuthTokenExpired()`)
|
|
|
|
```typescript
|
|
export function isOAuthTokenExpired(expiresAt: number | null): boolean {
|
|
if (expiresAt === null) return false // 만료 정보 없으면 유효로 처리
|
|
|
|
const bufferTime = 5 * 60 * 1000 // 5분 버퍼
|
|
const expiresWithBuffer = Date.now() + bufferTime
|
|
return expiresWithBuffer >= expiresAt
|
|
}
|
|
```
|
|
|
|
만료 5분 전을 만료로 처리하는 버퍼 설계는 `jwtUtils.ts`의 `TOKEN_REFRESH_BUFFER_MS`와 동일한 값을 사용하여 일관성을 유지한다.
|
|
|
|
### 9. 환경변수 폴백 메커니즘 (`populateOAuthAccountInfoIfNeeded()`)
|
|
|
|
SDK 통합 시나리오(예: Cowork)에서는 환경변수로 계정 정보를 주입할 수 있다:
|
|
|
|
```typescript
|
|
const envAccountUuid = process.env.CLAUDE_CODE_ACCOUNT_UUID
|
|
const envUserEmail = process.env.CLAUDE_CODE_USER_EMAIL
|
|
const envOrganizationUuid = process.env.CLAUDE_CODE_ORGANIZATION_UUID
|
|
|
|
if (envAccountUuid && envUserEmail && envOrganizationUuid) {
|
|
storeOAuthAccountInfo({ accountUuid: envAccountUuid, emailAddress: envUserEmail, ... })
|
|
}
|
|
```
|
|
|
|
환경변수 설정 후에도 실제 프로필 API 조회가 성공하면 API 응답이 환경변수 값을 덮어쓴다. 초기 텔레메트리 이벤트에서 계정 정보가 누락되는 경쟁 조건을 해결하기 위한 설계다.
|
|
|
|
---
|
|
|
|
## 설계 결정
|
|
|
|
### PKCE 의무화
|
|
|
|
모든 OAuth 플로우에서 PKCE가 필수다. 클라이언트 시크릿을 사용하지 않는 공개 클라이언트(CLI 도구)에서 인증 코드 가로채기 공격을 방지하기 위함이다. `code_challenge_method: 'S256'` (SHA-256)이 평문 방식(`plain`)보다 항상 선호된다.
|
|
|
|
### 이중 플로우 동시 경쟁
|
|
|
|
자동 플로우(브라우저 리다이렉트)와 수동 플로우(코드 붙여넣기)가 동시에 준비되어 먼저 완료되는 쪽이 채택된다. 헤드리스 서버 환경에서 브라우저를 열 수 없는 경우에도 수동 플로우로 인증이 가능하다. `skipBrowserOpen` 옵션은 이 패턴을 SDK 호출자가 완전히 제어할 수 있도록 확장한 것이다.
|
|
|
|
### 조직 UUID 조회 전략
|
|
|
|
`getOrganizationUUID()`는 네트워크 왕복을 최소화하기 위해 전역 설정을 우선 확인한다:
|
|
|
|
```typescript
|
|
// 1순위: 전역 설정 캐시
|
|
const orgUUID = globalConfig.oauthAccount?.organizationUuid
|
|
if (orgUUID) return orgUUID
|
|
|
|
// 2순위: 프로필 API (user:profile 스코프 필요)
|
|
const profile = await getOauthProfileFromOauthToken(accessToken)
|
|
```
|
|
|
|
`user:profile` 스코프가 없는 토큰은 프로필 API를 호출할 수 없으므로 `hasProfileScope()` 검사 후 폴백한다.
|
|
|
|
### 역할 정보 별도 API (`fetchAndStoreUserRoles()`)
|
|
|
|
`/roles` 엔드포인트에서 조직 역할(`organization_role`), 워크스페이스 역할(`workspace_role`), 조직명을 가져와 전역 설정에 저장한다. 프로필 API와 별개의 엔드포인트를 사용하는 이유는 역할 정보가 자주 변경될 수 있어 선택적으로 새로고침이 필요하기 때문이다.
|
|
|
|
### API 키 프로비저닝 (`createAndStoreApiKey()`)
|
|
|
|
OAuth 플로우 완료 후 자동으로 API 키를 생성하여 Keychain에 저장하는 옵션을 제공한다. 이는 OAuth 토큰 없이 직접 API 키로 인증하는 시나리오를 지원하며, 특히 자동화 환경에서 유용하다.
|
|
|
|
---
|
|
|
|
## 관련 파일 참조
|
|
|
|
| 파일 | 역할 |
|
|
|------|------|
|
|
| `src/services/oauth/index.ts` | `OAuthService` 메인 클래스 |
|
|
| `src/services/oauth/auth-code-listener.ts` | 로컬 OAuth 콜백 HTTP 서버 |
|
|
| `src/services/oauth/client.ts` | URL 빌더, 토큰 교환, 갱신, 프로필 조회 |
|
|
| `src/services/oauth/crypto.ts` | PKCE 암호 유틸리티 |
|
|
| `src/services/oauth/getOauthProfile.ts` | 프로필 API 호출 |
|
|
| `src/constants/oauth.ts` | OAuth 설정 (엔드포인트 URL, 클라이언트 ID, 스코프) |
|
|
| `src/utils/auth.ts` | Keychain 연동, 토큰 저장/조회, 갱신 조율 |
|
|
| `src/bridge/jwtUtils.ts` | JWT 디코딩, 브릿지 세션 토큰 갱신 스케줄러 |
|
|
|
|
---
|
|
|
|
## 탐색 링크
|
|
|
|
- [IDE 브릿지 분석](./bridge-ide.md)
|
|
- [컨텍스트 압축 & 토큰 관리](./context-compression.md)
|
|
- [Level 2: 아키텍처 개요](../level-2-architecture/)
|