정적 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 무시.
178 lines
5.9 KiB
Go
178 lines
5.9 KiB
Go
package anthropic
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Federation authenticates with Workload Identity Federation instead of an API key (platform.claude.com/docs/en/
|
|
// manage-claude/wif-providers/gcp): the VM's Google-signed identity token (metadata server, audience
|
|
// https://api.anthropic.com, format=full so the email claim is present) is exchanged at POST /v1/oauth/token
|
|
// (RFC 7523 jwt-bearer grant) for a short-lived sk-ant-oat01-... access token bound to an Anthropic service account.
|
|
// The four ids are not secrets; they come from ai.env. The token is cached and re-exchanged 120 s before expiry
|
|
// (the SDKs' advisory refresh), and a 401 from the API triggers one re-exchange. A fresh identity token is fetched
|
|
// for every exchange (identity tokens with a jti claim are single-use on the Anthropic side).
|
|
type Federation struct {
|
|
RuleID string // fdrl_...
|
|
OrgID string // Anthropic organization id (uuid)
|
|
ServiceAccountID string // svac_...
|
|
WorkspaceID string // wrkspc_... (optional when the rule covers one workspace)
|
|
IdentityTokenURL string // metadata server URL (tests override it)
|
|
HTTP *http.Client
|
|
|
|
mu sync.Mutex
|
|
token string
|
|
expiry time.Time
|
|
nex int // exchanges performed (tests)
|
|
}
|
|
|
|
const defaultIdentityTokenURL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://api.anthropic.com&format=full"
|
|
|
|
const refreshBefore = 120 * time.Second
|
|
|
|
// Configured reports whether the mandatory ids are set (then federation takes precedence over the key file).
|
|
func (f *Federation) Configured() bool {
|
|
return f != nil && f.RuleID != "" && f.OrgID != "" && f.ServiceAccountID != ""
|
|
}
|
|
|
|
func (f *Federation) identityToken(ctx context.Context) (string, error) {
|
|
u := f.IdentityTokenURL
|
|
if u == "" {
|
|
u = defaultIdentityTokenURL
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Metadata-Flavor", "Google")
|
|
resp, err := f.http().Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("identity token: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
|
tok := strings.TrimSpace(string(b))
|
|
if resp.StatusCode != 200 || strings.Count(tok, ".") != 2 {
|
|
return "", fmt.Errorf("identity token: status %d: %s", resp.StatusCode, truncate(tok, 200))
|
|
}
|
|
return tok, nil
|
|
}
|
|
|
|
func (f *Federation) http() *http.Client {
|
|
if f.HTTP != nil {
|
|
return f.HTTP
|
|
}
|
|
return &http.Client{Timeout: 30 * time.Second}
|
|
}
|
|
|
|
// AccessToken returns a cached access token, exchanging a fresh identity token when none is cached or the cached
|
|
// one is within refreshBefore of its expiry.
|
|
func (f *Federation) AccessToken(ctx context.Context, baseURL string) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.token != "" && time.Until(f.expiry) > refreshBefore {
|
|
return f.token, nil
|
|
}
|
|
tok, exp, err := f.exchange(ctx, baseURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
f.token, f.expiry = tok, exp
|
|
f.nex++
|
|
return tok, nil
|
|
}
|
|
|
|
// Invalidate drops the cached token (after a 401) so the next call exchanges again.
|
|
func (f *Federation) Invalidate() {
|
|
f.mu.Lock()
|
|
f.token, f.expiry = "", time.Time{}
|
|
f.mu.Unlock()
|
|
}
|
|
|
|
// Exchanges counts the token exchanges performed (tests).
|
|
func (f *Federation) Exchanges() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.nex
|
|
}
|
|
|
|
type tokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Error string `json:"error"`
|
|
ErrorDesc string `json:"error_description"`
|
|
ErrorObj *struct {
|
|
Type string `json:"type"`
|
|
Message string `json:"message"`
|
|
} `json:"error_obj,omitempty"`
|
|
}
|
|
|
|
func (f *Federation) exchange(ctx context.Context, baseURL string) (string, time.Time, error) {
|
|
jwt, err := f.identityToken(ctx)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
body := map[string]string{
|
|
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
"assertion": jwt,
|
|
"federation_rule_id": f.RuleID,
|
|
"organization_id": f.OrgID,
|
|
"service_account_id": f.ServiceAccountID,
|
|
}
|
|
if f.WorkspaceID != "" {
|
|
body["workspace_id"] = f.WorkspaceID
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
req, err := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(baseURL, "/")+"/v1/oauth/token", bytes.NewReader(b))
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := f.http().Do(req)
|
|
if err != nil {
|
|
return "", time.Time{}, fmt.Errorf("token exchange: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
var tr tokenResponse
|
|
_ = json.Unmarshal(rb, &tr)
|
|
if resp.StatusCode/100 != 2 || tr.AccessToken == "" {
|
|
// the API answers a failed exchange with an opaque 401 "Authentication failed"; the deny reason is on the
|
|
// Console authentication-history page (platform.claude.com/settings/workload-identity-federation?tab=history)
|
|
return "", time.Time{}, fmt.Errorf("token exchange: status %d: %s", resp.StatusCode, truncate(strings.TrimSpace(string(rb)), 300))
|
|
}
|
|
ttl := time.Duration(tr.ExpiresIn) * time.Second
|
|
if ttl <= 0 {
|
|
ttl = 60 * time.Second
|
|
}
|
|
return tr.AccessToken, time.Now().Add(ttl), nil
|
|
}
|
|
|
|
// Probe performs one exchange and reports the outcome (selfcheck): the setup can be verified without a diagnosis.
|
|
func (f *Federation) Probe(ctx context.Context, baseURL string) map[string]any {
|
|
out := map[string]any{"rule_id": f.RuleID, "service_account_id": f.ServiceAccountID}
|
|
f.Invalidate()
|
|
start := time.Now()
|
|
tok, err := f.AccessToken(ctx, baseURL)
|
|
out["ms"] = time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
out["ok"] = false
|
|
out["error"] = err.Error()
|
|
return out
|
|
}
|
|
f.mu.Lock()
|
|
out["ok"] = true
|
|
out["token_prefix"] = truncate(tok, 12)
|
|
out["expires_in_s"] = int(time.Until(f.expiry).Seconds())
|
|
f.mu.Unlock()
|
|
return out
|
|
}
|