레슨 25

OAuth 2.0 인증

PKCE 흐름 · 토큰 저장소 · 자동 및 수동 인증 · 프로필 조회 · 토큰 갱신

개요

Claude Code는 PKCE를 사용하는 OAuth 2.0 Authorization Code flow (Proof Key for Code Exchange)를 구현해 Anthropic Console 또는 Claude.ai를 상대로 사용자를 인증합니다. 바이너리 안에는 client secret이 들어 있지 않습니다. 대신 암호학적 verifier/challenge 쌍을 사용해 각 인가 요청을 위조할 수 없게 만듭니다.

구현은 세 계층에 걸쳐 있습니다:

  • 암호 기본 요소, services/oauth/crypto.ts: PKCE verifier, S256 challenge, CSRF state를 생성합니다.
  • 네트워크 클라이언트, services/oauth/client.ts: 인증 URL을 만들고, 코드를 토큰으로 교환하고, 토큰을 갱신하고, 사용자 프로필을 가져옵니다.
  • 오케스트레이터, services/oauth/index.ts (OAuthService): 리스너를 연결하고, 자동 및 수동 흐름을 경쟁시키고, 최종 토큰 객체를 포맷합니다.
두 가지 인가 대상: Console (platform.claude.com/oauth/authorize)은 API key 기반 워크플로에 사용됩니다. Claude.ai (claude.com/cai/oauth/authorize)는 Pro, Max, Team, Enterprise 구독자가 자신의 claude.ai 계정으로 직접 인증할 때 사용됩니다.

PKCE 기본 요소

PKCE는 각 흐름을 시작한 프로세스만 알고 있는 일회성 비밀값에 묶어서, authorization code 가로채기를 막습니다.

crypto.ts, 전체 소스
import { createHash, randomBytes } from 'crypto'

function base64URLEncode(buffer: Buffer): string {
  return buffer
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g,  '')   // RFC 4648 §5, URL 안전 형식, 패딩 없음
}

export function generateCodeVerifier(): string {
  return base64URLEncode(randomBytes(32))  // 256비트 엔트로피
}

export function generateCodeChallenge(verifier: string): string {
  const hash = createHash('sha256')
  hash.update(verifier)
  return base64URLEncode(hash.digest())  // S256 방식
}

export function generateState(): string {
  return base64URLEncode(randomBytes(32))  // CSRF 보호
}
crypto.ts
생성 방법서버 전송목적
codeVerifier 무작위 32바이트, base64url 토큰 교환 때만 이 프로세스가 흐름을 시작했음을 증명
codeChallenge SHA-256(verifier), base64url 인가 URL 파라미터 서버가 저장해 두었다가 나중에 verifier를 검증
state 무작위 32바이트, base64url 인증 URL + callback URL CSRF 방지, callback이 정확히 같은 state를 돌려줘야 함
codeVerifier는 서버가 시작되기 전에 OAuthServiceconstructor 내부에서 생성됩니다. 즉, 로그인 시도가 살아 있는 동안에만 메모리에 존재합니다.

전체 PKCE 시퀀스 다이어그램

아래 다이어그램은 완전한 자동 OAuth 흐름을 보여줍니다. 수동 폴백은 "브라우저 열기" 단계에서 갈라지며, 다음 섹션에서 설명합니다.

sequenceDiagram participant U as 사용자 participant CC as Claude Code
(CLI) participant LS as LocalServer
:PORT/callback participant B as 브라우저 participant AS as 인증 서버
claude.com / platform participant TS as 토큰 서버
platform.claude.com/v1/oauth/token participant PS as 프로필 API
api.anthropic.com CC->>CC: generateCodeVerifier()
generateCodeChallenge()
generateState() CC->>LS: AuthCodeListener.start()
(OS가 포트 할당) CC->>B: openBrowser(automaticFlowUrl)
?code_challenge=S256&state=...&redirect_uri=localhost:PORT CC-->>U: 터미널에 수동 URL 폴백 표시 B->>AS: GET /oauth/authorize?client_id=...&code_challenge=... AS->>U: 로그인 페이지 (email / SSO / magic link) U->>AS: 인증 수행 AS->>B: 302 → http://localhost:PORT/callback?code=AUTH_CODE&state=STATE B->>LS: GET /callback?code=AUTH_CODE&state=STATE LS->>LS: state === expectedState 검증 LS-->>CC: resolve(authorizationCode) CC->>TS: POST /v1/oauth/token
{ grant_type: authorization_code, code, code_verifier, redirect_uri } TS-->>CC: { access_token, refresh_token, expires_in, scope } CC->>PS: GET /api/oauth/profile
Authorization: Bearer access_token PS-->>CC: { account, organization } CC->>LS: handleSuccessRedirect(scopes)
→ 302 성공 페이지 CC->>CC: installOAuthTokens()
keychain / secure storage에 저장
OAuth 2.0 PKCE 자동 흐름, CLI 시작부터 keychain에 토큰이 저장될 때까지

자동 vs. 수동 인증 흐름

두 흐름은 동시에 시작됩니다. 먼저 authorization code를 전달하는 쪽이 승리합니다. 핵심은 OAuthService가 하나의 Promise를 두 resolver에 경쟁시킨다는 점입니다.

자동 (브라우저 리다이렉트)

  • AuthCodeListener가 OS가 할당한 포트에서 HTTP 서버를 시작합니다
  • 브라우저가 redirect_uri=localhost:PORT/callback가 포함된 automaticFlowUrl을 엽니다
  • 로그인 후 인증 서버가 브라우저를 로컬 서버로 리다이렉트합니다
  • callback 핸들러가 state를 검증하고 auth code Promise를 resolve합니다
  • 브라우저는 platform.claude.com/oauth/code/success로 성공 리다이렉트를 받습니다

수동 (복사-붙여넣기 폴백)

  • 터미널이 redirect_uri=platform.claude.com/oauth/code/callback가 포함된 manualFlowUrl을 표시합니다
  • 사용자가 해당 URL을 열고 인증한 뒤, 브라우저에서 결과 코드를 복사합니다
  • 사용자가 그 코드를 Claude Code 터미널 프롬프트에 붙여 넣습니다
  • handleManualAuthCodeInput()이 저장된 resolver를 직접 호출합니다
  • localhost에 접근할 수 없는 SSH 세션이나 환경에서 사용됩니다
OAuthService.startOAuthFlow(), 오케스트레이션 로직
async startOAuthFlow(
  authURLHandler: (url: string, automaticUrl?: string) => Promise<void>,
  options?: { skipBrowserOpen?: boolean; inferenceOnly?: boolean; ... }
): Promise<OAuthTokens> {
  // 1. localhost callback 서버 시작
  this.authCodeListener = new AuthCodeListener()
  this.port = await this.authCodeListener.start()

  // 2. 같은 PKCE 값으로 두 URL 구성
  const manualFlowUrl    = client.buildAuthUrl({ ...opts, isManual: true })
  const automaticFlowUrl = client.buildAuthUrl({ ...opts, isManual: false })

  // 3. 경쟁: 자동(localhost) 대 수동(붙여 넣기)
  const authorizationCode = await this.waitForAuthorizationCode(state, async () => {
    if (options?.skipBrowserOpen) {
      await authURLHandler(manualFlowUrl, automaticFlowUrl)  // SDK 모드
    } else {
      await authURLHandler(manualFlowUrl)   // 사용자에게 수동 URL 표시
      await openBrowser(automaticFlowUrl)  // 자동 흐름 시도
    }
  })

  // 4. 어느 흐름이 이겼는가?
  const isAutomatic = this.authCodeListener?.hasPendingResponse() ?? false

  // 5. 코드를 토큰으로 교환
  const tokenResponse = await client.exchangeCodeForTokens(
    authorizationCode, state, this.codeVerifier, this.port!,
    !isAutomatic  // auto가 이기지 못했다면 isManual = true
  )

  // 6. 프로필 API에서 구독 및 rate-limit tier 조회
  const profileInfo = await client.fetchProfileInfo(tokenResponse.access_token)

  // 7. 브라우저를 성공 페이지로 리다이렉트한 뒤 정리
  if (isAutomatic) this.authCodeListener?.handleSuccessRedirect(scopes)
  return this.formatTokens(tokenResponse, profileInfo.subscriptionType, ...)
}
services/oauth/index.ts
skipBrowserOpen 모드: SDK control protocol (claude_authenticate)이 로그인을 주도할 때는 skipBrowserOpen: true를 설정합니다. 두 URL 모두 authURLHandler를 통해 호출자에게 전달되며, 어디서 열지는 Claude Code가 아니라 SDK 클라이언트가 결정합니다.

AuthCodeListener, 로컬호스트 캡처 서버

AuthCodeListener는 OAuth 제공자의 리다이렉트를 받아 대기 중인 Promise에 authorization code를 넘겨주는 일만 하는 최소한의 Node.js HTTP 서버입니다.

핵심 구현 세부 사항

포트 할당

포트 0으로 listen하면 OS가 빈 포트를 골라 줍니다. 그래서 "port already in use" 류의 오류를 아예 피할 수 있습니다. 선택된 포트는 인증 서버가 callback에 사용하는 redirect_uri 안에 포함됩니다.

State 검증

callback이 도착하면 validateAndRespond()는 resolve하기 전에 state === expectedState인지 확인합니다. 값이 다르면 HTTP 400을 반환하고 promise를 reject해서 CSRF를 막습니다.

대기 응답 패턴

서버는 auth code promise를 resolve하기 전에 브라우저의 ServerResponse 객체를 pendingResponse에 저장합니다. 토큰 교환이 성공하면 handleSuccessRedirect()가 성공 페이지로의 302 응답으로 브라우저 요청을 마무리합니다. 덕분에 브라우저 탭이 멈춘 채 남지 않습니다.

닫기 시 정리

응답이 아직 대기 중인 상태에서 close()가 호출되면, 예를 들어 토큰 교환이 실패한 경우에도 서버는 먼저 handleErrorRedirect()를 호출해 브라우저가 항상 응답을 받도록 보장합니다.

AuthCodeListener.validateAndRespond(), CSRF 검사
private validateAndRespond(
  authCode: string | undefined,
  state:    string | undefined,
  res:      ServerResponse,
): void {
  if (!authCode) {
    res.writeHead(400)
    res.end('Authorization code not found')
    this.reject(new Error('No authorization code received'))
    return
  }
  if (state !== this.expectedState) {
    res.writeHead(400)
    res.end('Invalid state parameter')
    this.reject(new Error('Invalid state parameter'))
    return
  }
  // 나중에 리다이렉트할 수 있도록 response를 저장, 브라우저가 멈추지 않게 함
  this.pendingResponse = res
  this.resolve(authCode)
}
auth-code-listener.ts

토큰 교환

authorization code를 확보하면, 토큰 엔드포인트로 POST를 보내 access token과 refresh token으로 교환합니다.

exchangeCodeForTokens(), 요청 본문
const requestBody = {
  grant_type:    'authorization_code',
  code:           authorizationCode,
  redirect_uri:   useManualRedirect
    ? getOauthConfig().MANUAL_REDIRECT_URL    // https://platform.claude.com/oauth/code/callback
    : `http://localhost:${port}/callback`,    // auth URL에 들어간 값과 정확히 일치해야 함
  client_id:      getOauthConfig().CLIENT_ID, // '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
  code_verifier:  codeVerifier,               // 우리가 이 흐름을 시작했음을 증명
  state,
}

// POST https://platform.claude.com/v1/oauth/token
const response = await axios.post(TOKEN_URL, requestBody, {
  headers: { 'Content-Type': 'application/json' },
  timeout: 15000,
})
client.ts

토큰 요청의 redirect_uri는 인가 요청에서 사용한 값과 정확히 같아야 합니다. 이 제약은 서버 측에서 강제되며, 재사용 공격을 막는 또 하나의 장치입니다.

inferenceOnly 토큰: inferenceOnly: true일 때는 user:inference scope만 요청합니다. 이는 전체 scope 세트가 과한 SDK 프로그래매틱 접근을 위해 설계된 장기 수명 토큰입니다.

OAuth 스코프

Scope는 발급된 access token이 무엇을 할 수 있는지 결정합니다. Claude Code는 로그인 시 Console과 Claude.ai의 scope 합집합을 요청해서, 하나의 토큰으로 두 경로를 모두 처리할 수 있게 합니다.

요청되는 전체 스코프 (ALL_OAUTH_SCOPES)

org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload
스코프용도
org:create_api_keyConsole 경로, 조직용 영구 API key 생성
user:profile/api/oauth/profile에서 구독 유형, rate-limit tier, 계정 및 조직 정보 조회
user:inferenceClaude.ai 경로, 추론 요청을 Claude.ai 구독을 통해 직접 라우팅
user:sessions:claude_codeClaude Code 클라이언트 전용 세션 관리
user:mcp_servers계정에 연결된 MCP 서버 접근 및 설정
user:file_upload처리를 위해 Anthropic 인프라로 파일 업로드

shouldUseClaudeAIAuth(scopes) 함수는 user:inference가 포함되어 있는지 확인합니다. 포함되어 있으면 추론 호출은 Claude.ai 인프라를 통해 라우팅되고, 그렇지 않으면 Console API key 경로가 사용됩니다.

프로필 가져오기

토큰 교환이 끝나자마자 Claude Code는 사용자의 프로필을 가져와 구독 유형과 rate-limit tier를 판단합니다. 이 프로필 정보는 UI 선택, 모델 사용 가능 여부, 기능 플래그에 영향을 줍니다.

fetchProfileInfo(), 구독 유형 매핑
export async function fetchProfileInfo(accessToken: string) {
  const profile = await getOauthProfileFromOauthToken(accessToken)
  const orgType = profile?.organization?.organization_type

  let subscriptionType: SubscriptionType | null = null
  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
  }
  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,
  }
}
client.ts

구독 유형과 org_type 키

max
claude_max
pro
claude_pro
team
claude_team
enterprise
claude_enterprise
프로필 생략 최적화: 일반적인 토큰 갱신 중에 global config에 이미 billingType, accountCreatedAt, subscriptionCreatedAt가 있고, secure storage에도 null이 아닌 subscriptionTyperateLimitTier가 있으면 프로필 엔드포인트 호출을 완전히 건너뜁니다. 이 최적화는 전체 fleet 기준으로 하루 약 700만 건의 요청을 줄입니다.

토큰 저장소, 키체인 아키텍처

토큰은 평문 config 파일이 아니라 플랫폼별 secure storage에 저장됩니다. 어떤 저장 계층을 쓸지는 런타임에 getSecureStorage()가 선택합니다.

플랫폼기본 저장소폴백
macOS macOsKeychainStorage, macOS security CLI (add-generic-password / find-generic-password) 사용 plainTextStorage, ~/.claude/ 안의 암호화된 JSON
Linux plainTextStorage (libsecret 지원 예정)
Windows plainTextStorage
macOS keychain, 왜 hex와 stdin을 쓰는가?

자격 증명을 저장할 때 macOS security 명령을 호출합니다. 여기에는 눈에 띄는 보안 엔지니어링 결정이 두 가지 있습니다:

  • Hex 인코딩: JSON 토큰 payload는 저장 전에 16진수로 변환됩니다 (-X 플래그). 이렇게 하면 셸 quoting 문제를 피할 수 있고, 더 중요하게는 CrowdStrike 같은 프로세스 모니터가 ps 출력이나 시스템 호출 로그에서 원시 토큰 값을 보지 못하게 합니다.
  • stdin 우선 (security -i): payload가 4032바이트 이내에 들어오면 (4096 - 64 여유분), 명령행 인자 대신 stdin으로 전달합니다. 덕분에 토큰이 프로세스 인자 목록에 나타나지 않습니다. payload가 한도를 넘으면 debug 경고와 함께 argv 폴백을 사용합니다.
  • Stale-while-error: security 서브프로세스가 일시적으로 실패하면, 사용자에게 "Not logged in" 오류를 보여주는 대신 마지막으로 정상적이었던 값을 캐시에서 제공합니다.
installOAuthTokens(), 흐름 이후에 일어나는 일
export async function installOAuthTokens(tokens: OAuthTokens): Promise<void> {
  // 1. 먼저 이전 상태를 삭제 (keychain 정리, 캐시 초기화)
  await performLogout({ clearOnboarding: false })

  // 2. 계정 정보를 global config에 저장 (민감하지 않은 JSON)
  const profile = tokens.profile ?? await getOauthProfileFromOauthToken(tokens.accessToken)
  if (profile) {
    storeOAuthAccountInfo({ accountUuid, emailAddress, organizationUuid, ... })
  }

  // 3. 토큰을 secure storage에 저장 (macOS에서는 keychain)
  const storageResult = saveOAuthTokensIfNeeded(tokens)
  clearOAuthTokenCache()

  // 4. 역할 조회 (org/workspace role), 중요하지 않으므로 실패 허용
  await fetchAndStoreUserRoles(tokens.accessToken).catch(logForDebugging)

  // 5. Console 경로, 토큰을 통해 영구 API key 생성
  if (!shouldUseClaudeAIAuth(tokens.scopes)) {
    await createAndStoreApiKey(tokens.accessToken)
  }
}
cli/handlers/auth.ts

토큰 갱신

Access token은 만료됩니다. Claude Code는 만료 5분 전 버퍼 구간을 두고 미리 갱신합니다. 이 갱신 흐름은 사용자가 거의 의식하지 못하도록 설계되어 있습니다.

isOAuthTokenExpired(), 버퍼 검사
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
}
client.ts
refreshOAuthToken(), scope 확장과 중복 제거
export async function refreshOAuthToken(
  refreshToken: string,
  { scopes: requestedScopes }: { scopes?: string[] } = {},
): Promise<OAuthTokens> {
  const requestBody = {
    grant_type:    'refresh_token',
    refresh_token: refreshToken,
    client_id:     getOauthConfig().CLIENT_ID,
    // 백엔드는 refresh 시 scope 확장을 허용함 (ALLOWED_SCOPE_EXPANSIONS)
    scope: (requestedScopes?.length ? requestedScopes : CLAUDE_AI_OAUTH_SCOPES).join(' '),
  }

  // 이미 모든 필드가 캐시에 있으면 프로필 조회 생략
  const haveProfileAlready =
    config.oauthAccount?.billingType !== undefined &&
    config.oauthAccount?.accountCreatedAt !== undefined &&
    existing?.subscriptionType != null  // secure storage도 함께 확인해야 함

  const profileInfo = haveProfileAlready ? null : await fetchProfileInfo(accessToken)

  return {
    accessToken,
    refreshToken: newRefreshToken,   // 서버가 refresh token을 회전시킬 수 있음
    expiresAt:    Date.now() + expiresIn * 1000,
    scopes,
    subscriptionType: profileInfo?.subscriptionType ?? existing?.subscriptionType ?? null,
    rateLimitTier:    profileInfo?.rateLimitTier    ?? existing?.rateLimitTier    ?? null,
  }
}
client.ts
env var를 통한 재로그인: CLAUDE_CODE_OAUTH_REFRESH_TOKEN이 설정되면 Claude Code는 이를 사용해 새 토큰 교환을 수행합니다. 여기서 중요한 미묘한 점은 installOAuthTokens가 refresh가 반환된 뒤에 performLogout()를 호출한다는 것입니다. 만약 refreshOAuthToken이 프로필 필드 누락을 보고 subscriptionType: null을 반환하면, 이미 logout으로 지워진 뒤이기 때문에 이후 갱신에서 구독 유형을 영구히 잃게 됩니다. 해결책은 logout이 지우기 전에 secure storage의 캐시 값을 그대로 전달하는 것입니다.

로그아웃 흐름

로그아웃은 토큰 하나를 지우는 것보다 큽니다. 현재 신원에 의존하는 모든 상태 계층을 함께 정리해야 합니다.

performLogout(), 전체 해제 순서
export async function performLogout({ clearOnboarding = false }): Promise<void> {
  // 1. 자격 증명을 지우기 전에 telemetry를 먼저 flush
  //    계정이 지워진 뒤에 조직 귀속 이벤트가 전송되는 일을 막음
  const { flushTelemetry } = await import('../../utils/telemetry/instrumentation.js')
  await flushTelemetry()

  await removeApiKey()

  // 2. secure storage 삭제 (keychain의 토큰 포함)
  const secureStorage = getSecureStorage()
  secureStorage.delete()

  // 3. 인증에 의존하는 모든 메모리 캐시 정리
  await clearAuthRelatedCaches()

  // 4. global config 업데이트
  saveGlobalConfig(current => ({
    ...current,
    oauthAccount: undefined,        // 계정 정보 제거
    ...(clearOnboarding && {
      hasCompletedOnboarding: false,
      subscriptionNoticeCount: 0,
      hasAvailableSubscription: false,
    }),
  }))
}
commands/logout/logout.ts

clearAuthRelatedCaches()가 무효화하는 것:

  • OAuth token memoize cache (getClaudeAIOAuthTokens.cache?.clear())
  • Trusted device token cache
  • Betas 및 tool schema cache
  • User data cache (GrowthBook refresh 전에 반드시 비워야 함)
  • GrowthBook feature flag cache
  • Grove config cache (notice + settings)
  • 원격 관리 설정 cache
  • 정책 제한 cache
telemetry 우선 순서: 자격 증명을 지우기 전에 telemetry를 flush하면, 조직 귀속 정보를 담은 진행 중 이벤트가 올바른 계정 컨텍스트에서 전달됩니다. 지운 뒤에 flush하면 이 이벤트들이 "anonymous"로 전송되어 사용량 분석이 왜곡될 수 있습니다.

인가 URL 구성

buildAuthUrl()은 필요한 모든 OAuth + PKCE 파라미터와 선택적 힌트를 모아 authorization URL을 구성합니다.

buildAuthUrl(), 전체 파라미터 세트
export function buildAuthUrl({ codeChallenge, state, port, isManual,
  loginWithClaudeAi, inferenceOnly, orgUUID, loginHint, loginMethod }) {

  // 계정 유형에 따라 인증 서버 선택
  const authUrlBase = loginWithClaudeAi
    ? 'https://claude.com/cai/oauth/authorize'   // 307으로 claude.ai로 이동
    : 'https://platform.claude.com/oauth/authorize'

  const authUrl = new URL(authUrlBase)
  authUrl.searchParams.append('code',              'true')  // Claude Max 업셀 표시
  authUrl.searchParams.append('client_id',         CLIENT_ID)
  authUrl.searchParams.append('response_type',     'code')
  authUrl.searchParams.append('redirect_uri',      isManual
    ? 'https://platform.claude.com/oauth/code/callback'
    : `http://localhost:${port}/callback`)
  authUrl.searchParams.append('scope',             scopesToUse.join(' '))
  authUrl.searchParams.append('code_challenge',    codeChallenge)
  authUrl.searchParams.append('code_challenge_method', 'S256')
  authUrl.searchParams.append('state',             state)

  // 선택 사항: 로그인 폼 미리 채우기 (표준 OIDC)
  if (loginHint)   authUrl.searchParams.append('login_hint',   loginHint)
  // 선택 사항: 특정 로그인 방식 요청
  if (loginMethod) authUrl.searchParams.append('login_method', loginMethod)
  // 선택 사항: 특정 org 지정
  if (orgUUID)     authUrl.searchParams.append('orgUUID',      orgUUID)

  return authUrl.toString()
}
client.ts
?code=true 파라미터는 로그인 페이지에 Claude Max 구독 업셀을 표시하라고 지시하는 Claude 전용 플래그입니다. 표준 OAuth 파라미터는 아닙니다.

엔터프라이즈 및 FedStart 구성

미국 연방 및 FedRAMP 배포 환경(FedStart)에서는 CLAUDE_CODE_CUSTOM_OAUTH_URL 환경 변수를 통해 모든 OAuth 엔드포인트를 승인된 base URL로 리디렉션할 수 있습니다.

getOauthConfig()에서의 허용 목록 강제
const ALLOWED_OAUTH_BASE_URLS = [
  'https://beacon.claude-ai.staging.ant.dev',
  'https://claude.fedstart.com',
  'https://claude-staging.fedstart.com',
]

const oauthBaseUrl = process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL
if (oauthBaseUrl) {
  const base = oauthBaseUrl.replace(/\/$/, '')
  if (!ALLOWED_OAUTH_BASE_URLS.includes(base)) {
    throw new Error('CLAUDE_CODE_CUSTOM_OAUTH_URL is not an approved endpoint.')
  }
  // 모든 OAuth URL을 FedStart 배포를 가리키도록 재설정
  config = { ...config, BASE_API_URL: base, CONSOLE_AUTHORIZE_URL: `${base}/oauth/authorize`, ... }
}
constants/oauth.ts

엄격한 허용 목록 검사는 이 override가 OAuth 토큰을 임의의 서버로 보내는 데 악용되지 못하게 막습니다. 즉, 자격 증명 유출 공격을 차단합니다.

핵심 요약

1
바이너리 안에 client secret이 없다. 그 역할을 PKCE가 대신합니다. code verifier는 로그인 시도마다 새로 생성되고, 메모리에만 존재하며, 저장되지 않습니다. 서버가 보는 것은 S256 challenge입니다.
2
자동 흐름과 수동 흐름은 서로 경쟁한다. 두 흐름은 동시에 시작됩니다. 자동 흐름은 브라우저를 열고 임시 localhost 서버를 통해 리다이렉트를 받습니다. 수동 흐름은 사용자가 아무 브라우저에서나 열 수 있는 URL을 보여주고, 그 결과 코드를 붙여 넣게 합니다.
3
macOS에서는 토큰이 OS keychain에 저장된다. 프로세스 인자 목록과 프로세스 모니터에 드러나지 않도록 security -i (stdin)를 통해 hex 형태로 저장합니다. stale-while-error 캐시는 일시적인 서브프로세스 실패 때문에 사용자가 로그아웃되지 않게 막아 줍니다.
4
갱신 중에는 프로필 조회가 최적화로 생략된다. 필요한 프로필 필드가 이미 global config와 secure storage에 모두 캐시되어 있다면 /api/oauth/profile 호출을 건너뜁니다. 이 최적화는 전체 fleet 기준 하루 수백만 건의 API 호출을 없앱니다.
5
로그아웃은 telemetry를 먼저 flush한다. 진행 중인 분석 이벤트에는 조직 귀속 정보가 담겨 있습니다. 자격 증명을 먼저 지우면 이 이벤트들이 anonymous로 전송되어 사용량 데이터가 왜곡됩니다. 이 순서는 의도된 것이며 코드에도 문서화되어 있습니다.
6
scope에 따라 두 개의 별도 인증 경로가 있다. user:inference scope가 있으면 Claude.ai 구독자이며, 추론은 Claude.ai 인프라를 통해 직접 처리됩니다. 이 scope가 없으면 Console 경로를 쓰고, 로그인 후 API key를 만들어 모든 요청에 사용합니다.
7
토큰 갱신은 scope를 확장할 수 있다. 백엔드의 ALLOWED_SCOPE_EXPANSIONS 덕분에 refresh grant는 최초 인가 때 받은 범위를 넘어선 scope를 포함할 수 있습니다. 그래서 사용자가 처음 로그인한 뒤 추가된 새 scope도 재로그인 없이 다음 토큰 갱신 때 반영할 수 있습니다.

이해도 확인

Q1 왜 토큰 요청에는 code_verifier가 포함되지만, 인가 요청에는 code_challenge만 포함될까요?
Q2 사용자의 브라우저가 localhost:PORT/callback에 접근할 수 없으면 어떻게 될까요?
Q3 무엇이 shouldUseClaudeAIAuth()true를 반환하게 만들까요?
Q4performLogout()에서는 자격 증명을 지우기 전에 telemetry를 flush할까요?
Q5 macOS에서는 왜 keychain payload를 저장 전에 hex로 변환할까요?
Q6 토큰 갱신 중 /api/oauth/profile 호출은 언제 생략될까요?