feat(ai): authenticate to the Claude API with Workload Identity Federation

정적 API 키 대신 Workload Identity Federation을 쓴다. 에이전트는 AI VM의 Google 서명
ID 토큰(메타데이터 서버, audience https://api.anthropic.com, format=full)을
POST /v1/oauth/token(jwt-bearer)으로 교환해 단기 access token을 받고 Authorization: Bearer로
호출한다. 토큰은 캐시하고 만료 120초 전에 재교환하며, 401이면 한 번 재교환한다.
규칙·조직·서비스 계정·워크스페이스 ID 4개는 비밀이 아니므로 ai.env 환경 변수로 받고,
비어 있으면 기존 키 파일 경로로 동작한다. /selfcheck가 auth 모드와 실제 교환 결과를
보고해 캠페인 없이 설정을 검증할 수 있다. 테스트: 교환 1회 재사용·401 재교환·만료 전
갱신·거부 응답·불완전 ID 무시.
This commit is contained in:
seon committed 2026-10-15 11:48:26 +09:00
1 parent 53ff243183
commit 684e70ba60
4 files changed
+387 -13

No files matched your search

+68 -13
View File
@@ -35,6 +35,7 @@ type Client struct {
BaseURL string
HTTP *http.Client
keyFn func() (string, error)
fed *Federation // nil = API key from keyFn; set = Workload Identity Federation (takes precedence)
mu sync.Mutex
conv map[string]conversation // response id -> messages (and system prompt) up to and including that assistant turn
@@ -60,6 +61,48 @@ func (c *Client) KeyPresent() bool {
return err == nil && k != ""
}
// SetFederation switches the client to Workload Identity Federation (ignored when the ids are incomplete).
func (c *Client) SetFederation(f *Federation) {
if f.Configured() {
c.fed = f
}
}
// AuthMode is "federation", "api_key" or "none" (reported by /selfcheck).
func (c *Client) AuthMode() string {
if c.fed != nil {
return "federation"
}
if c.KeyPresent() {
return "api_key"
}
return "none"
}
// Federation returns the active federation config (nil when the API key path is used).
func (c *Client) Federation() *Federation { return c.fed }
// authorize sets the credential header: a federated bearer token, or the API key.
func (c *Client) authorize(ctx context.Context, req *http.Request) error {
if c.fed != nil {
tok, err := c.fed.AccessToken(ctx, c.BaseURL)
if err != nil {
return fmt.Errorf("anthropic federation: %w", err)
}
req.Header.Set("Authorization", "Bearer "+tok)
return nil
}
key, err := c.keyFn()
if err != nil {
return fmt.Errorf("anthropic key: %w", err)
}
if key == "" {
return fmt.Errorf("anthropic api key is empty (secret selfheal-anthropic-api-key not registered yet) and federation is not configured")
}
req.Header.Set("x-api-key", key)
return nil
}
type message struct {
Role string `json:"role"`
Content any `json:"content"` // string or []block
@@ -114,12 +157,12 @@ const forcedNudge = "You must call the submit_diagnosis tool now with your final
// CreateResponse translates one loop turn to a Messages API call and the answer back to Responses-API items.
func (c *Client) CreateResponse(ctx context.Context, req openai.Request) (*openai.Response, error) {
key, err := c.keyFn()
if err != nil {
return nil, fmt.Errorf("anthropic key: %w", err)
}
if key == "" {
return nil, fmt.Errorf("anthropic api key is empty (secret selfheal-anthropic-api-key not registered yet)")
if c.fed == nil {
if key, err := c.keyFn(); err != nil {
return nil, fmt.Errorf("anthropic key: %w", err)
} else if key == "" {
return nil, fmt.Errorf("anthropic api key is empty (secret selfheal-anthropic-api-key not registered yet) and federation is not configured")
}
}
var msgs []message
var system []string
@@ -187,16 +230,28 @@ func (c *Client) CreateResponse(ctx context.Context, req openai.Request) (*opena
}
b, _ := json.Marshal(body)
hreq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/v1/messages", bytes.NewReader(b))
send := func() (*http.Response, error) {
hreq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/v1/messages", bytes.NewReader(b))
if err != nil {
return nil, err
}
if err := c.authorize(ctx, hreq); err != nil {
return nil, err
}
hreq.Header.Set("anthropic-version", apiVersion)
hreq.Header.Set("Content-Type", "application/json")
return c.HTTP.Do(hreq)
}
resp, err := send()
if err != nil {
return nil, err
}
hreq.Header.Set("x-api-key", key)
hreq.Header.Set("anthropic-version", apiVersion)
hreq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(hreq)
if err != nil {
return nil, err
if resp.StatusCode == 401 && c.fed != nil { // token revoked/expired early: exchange once more
resp.Body.Close()
c.fed.Invalidate()
if resp, err = send(); err != nil {
return nil, err
}
}
defer resp.Body.Close()
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))