Files
selfheal-lab-apps/ai/internal/anthropic/federation_test.go
T
seon 684e70ba60 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 무시.
2026-10-15 11:48:26 +09:00

131 lines
6.2 KiB
Go

package anthropic
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/openai"
)
// Federation: the identity token from the (fake) metadata server is exchanged once at /v1/oauth/token with the
// jwt-bearer grant and the four ids; /v1/messages gets "Authorization: Bearer" and no x-api-key; the cached token is
// reused; a 401 triggers exactly one re-exchange; an expired cache re-exchanges before the next call.
func TestFederationExchangeAndReuse(t *testing.T) {
var idCalls, exchanges, msgCalls int32
var fail401 atomic.Bool
md := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Metadata-Flavor") != "Google" || !strings.Contains(r.URL.RawQuery, "audience=https://api.anthropic.com") || !strings.Contains(r.URL.RawQuery, "format=full") {
t.Errorf("bad metadata request: %s %v", r.URL.String(), r.Header)
}
atomic.AddInt32(&idCalls, 1)
w.Write([]byte("eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20ifQ.sig"))
}))
defer md.Close()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/oauth/token":
var b map[string]string
_ = json.NewDecoder(r.Body).Decode(&b)
if b["grant_type"] != "urn:ietf:params:oauth:grant-type:jwt-bearer" || !strings.HasPrefix(b["assertion"], "eyJ") || b["federation_rule_id"] != "fdrl_1" || b["organization_id"] != "org-1" || b["service_account_id"] != "svac_1" || b["workspace_id"] != "wrkspc_1" {
t.Errorf("bad exchange body: %v", b)
}
n := atomic.AddInt32(&exchanges, 1)
w.Write([]byte(`{"access_token":"sk-ant-oat01-tok` + string(rune('0'+n)) + `","token_type":"Bearer","expires_in":600}`))
case "/v1/messages":
atomic.AddInt32(&msgCalls, 1)
if r.Header.Get("x-api-key") != "" || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer sk-ant-oat01-tok") {
t.Errorf("bad auth headers: %v", r.Header)
}
if fail401.Swap(false) {
w.WriteHeader(401)
w.Write([]byte(`{"type":"error","error":{"type":"authentication_error","message":"Authentication failed"}}`))
return
}
w.Write([]byte(`{"id":"msg_f","type":"message","model":"claude-sonnet-5-5","stop_reason":"end_turn","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":3,"output_tokens":1}}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer api.Close()
c := New(api.URL, func() (string, error) { return "", nil }) // no key file: federation must carry the call
fed := &Federation{RuleID: "fdrl_1", OrgID: "org-1", ServiceAccountID: "svac_1", WorkspaceID: "wrkspc_1", IdentityTokenURL: md.URL + "/identity?audience=https://api.anthropic.com&format=full"}
c.SetFederation(fed)
if c.AuthMode() != "federation" {
t.Fatalf("auth mode %s", c.AuthMode())
}
req := openai.Request{Model: "claude-sonnet-5-5", Input: []openai.Item{{Type: "message", Role: "user", Content: "hi"}}}
for i := 0; i < 2; i++ {
if _, err := c.CreateResponse(context.Background(), req); err != nil {
t.Fatal(err)
}
}
if atomic.LoadInt32(&exchanges) != 1 || atomic.LoadInt32(&idCalls) != 1 || atomic.LoadInt32(&msgCalls) != 2 {
t.Fatalf("expected one exchange for two calls, got exchanges=%d id=%d msgs=%d", exchanges, idCalls, msgCalls)
}
// 401 -> one re-exchange and a successful retry
fail401.Store(true)
if _, err := c.CreateResponse(context.Background(), req); err != nil {
t.Fatal(err)
}
if atomic.LoadInt32(&exchanges) != 2 || atomic.LoadInt32(&msgCalls) != 4 {
t.Fatalf("401 must re-exchange once: exchanges=%d msgs=%d", exchanges, msgCalls)
}
// cached token close to expiry -> advisory refresh before the next call
fed.mu.Lock()
fed.expiry = time.Now().Add(60 * time.Second)
fed.mu.Unlock()
if _, err := c.CreateResponse(context.Background(), req); err != nil {
t.Fatal(err)
}
if atomic.LoadInt32(&exchanges) != 3 {
t.Fatalf("near-expiry token must be refreshed: exchanges=%d", exchanges)
}
// probe reports a successful exchange
p := fed.Probe(context.Background(), api.URL)
if p["ok"] != true || p["expires_in_s"].(int) < 500 || !strings.HasPrefix(p["token_prefix"].(string), "sk-ant-oat01") {
t.Fatalf("probe: %v", p)
}
}
func TestFederationFailuresAndPrecedence(t *testing.T) {
// incomplete ids are ignored: the key path stays active
c := New("http://127.0.0.1:1", func() (string, error) { return "k", nil })
c.SetFederation(&Federation{RuleID: "fdrl_1"})
if c.AuthMode() != "api_key" || c.Federation() != nil {
t.Fatalf("incomplete federation must not activate: %s", c.AuthMode())
}
// exchange denied (opaque 401) -> clear error, no message call
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/oauth/token" {
w.WriteHeader(401)
w.Write([]byte(`{"type":"error","error":{"type":"authentication_error","message":"Authentication failed"}}`))
return
}
t.Errorf("messages must not be called when the exchange fails")
}))
defer api.Close()
md := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("a.b.c")) }))
defer md.Close()
c2 := New(api.URL, func() (string, error) { return "", nil })
fed := &Federation{RuleID: "fdrl_1", OrgID: "org-1", ServiceAccountID: "svac_1", IdentityTokenURL: md.URL}
c2.SetFederation(fed)
_, err := c2.CreateResponse(context.Background(), openai.Request{Model: "claude-haiku-5-5", Input: []openai.Item{{Type: "message", Role: "user", Content: "x"}}})
if err == nil || !strings.Contains(err.Error(), "token exchange: status 401") {
t.Fatalf("expected exchange failure, got %v", err)
}
if p := fed.Probe(context.Background(), api.URL); p["ok"] != false || !strings.Contains(p["error"].(string), "status 401") {
t.Fatalf("probe must report the denial: %v", p)
}
// metadata server unreachable
fed2 := &Federation{RuleID: "fdrl_1", OrgID: "org-1", ServiceAccountID: "svac_1", IdentityTokenURL: "http://127.0.0.1:1/identity", HTTP: &http.Client{Timeout: 2 * time.Second}}
if _, err := fed2.AccessToken(context.Background(), api.URL); err == nil || !strings.Contains(err.Error(), "identity token") {
t.Fatalf("expected identity token error, got %v", err)
}
}