Claude's forbindelsesdialog prøver OAuth først. Indtil nu var svaret 404, og eneste vej ind var at lave et token og sætte det ind i en adresse. Nu er instansen sin egen autorisationsserver for MCP-klienter: - /.well-known/oauth-protected-resource(/mcp) og /.well-known/oauth-authorization-server peger på login'et; en 401 fra /mcp bærer WWW-Authenticate med resource_metadata - /oauth/register (RFC 7591), kun offentlige klienter; registreringer, ingen godkender, ryddes efter et døgn - /oauth/authorize sender til /forbind, som logger ind med det almindelige login og spørger "Claude vil forbinde til din konto" — og viser, hvor adgangen sendes hen, fordi navnet er klientens eget - /oauth/token: authorization code + S256 PKCE, koden gælder ét minut og én gang; ud kommer et almindeligt API-token, listet under Tokens med klientens navn, og en ny forbindelse fra samme klient erstatter det - /mcp tager nu også Authorization: Bearer; ?key= virker stadig Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
399 lines
14 KiB
Go
399 lines
14 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Signing in to MCP with the account: the whole dance Claude performs, from the
|
|
// first 401 to a tool call made with the token it was handed.
|
|
|
|
const claudeCallback = "https://claude.ai/api/mcp/auth_callback"
|
|
|
|
// A verifier of the minimum legal length, and its S256 challenge.
|
|
var (
|
|
testVerifier = strings.Repeat("v", 43)
|
|
testChallenge = func() string {
|
|
sum := sha256.Sum256([]byte(testVerifier))
|
|
return base64.RawURLEncoding.EncodeToString(sum[:])
|
|
}()
|
|
)
|
|
|
|
func postJSON(t *testing.T, target, body string) (*http.Response, map[string]any) {
|
|
t.Helper()
|
|
resp, err := http.Post(target, "application/json", strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var decoded map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&decoded)
|
|
return resp, decoded
|
|
}
|
|
|
|
// registerClient registers the way Claude does, extra metadata included.
|
|
func registerClient(t *testing.T, ts *testServer, redirect string) string {
|
|
t.Helper()
|
|
resp, body := postJSON(t, ts.URL+"/oauth/register", `{
|
|
"client_name": "Claude",
|
|
"redirect_uris": ["`+redirect+`"],
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"],
|
|
"token_endpoint_auth_method": "none",
|
|
"scope": "claudeai"
|
|
}`)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("register: status %d, %v", resp.StatusCode, body)
|
|
}
|
|
id, _ := body["client_id"].(string)
|
|
if id == "" {
|
|
t.Fatalf("register: no client_id in %v", body)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func authorizeQuery(clientID, redirect string) string {
|
|
return url.Values{
|
|
"response_type": {"code"},
|
|
"client_id": {clientID},
|
|
"redirect_uri": {redirect},
|
|
"state": {"st4te"},
|
|
"code_challenge": {testChallenge},
|
|
"code_challenge_method": {"S256"},
|
|
"resource": {"http://localhost/mcp"},
|
|
}.Encode()
|
|
}
|
|
|
|
// approve is the approval page pressing "Tillad", as the signed-in ts.client.
|
|
// It returns the code from the address the browser is sent back to.
|
|
func approve(t *testing.T, ts *testServer, query string) string {
|
|
t.Helper()
|
|
resp, body := ts.do(t, "POST", "/api/v1/oauth/approve", map[string]any{
|
|
"query": "?" + query, "approve": true,
|
|
})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("approve: status %d, %v", resp.StatusCode, body)
|
|
}
|
|
back, err := url.Parse(body["redirect_to"].(string))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := back.Query().Get("state"); got != "st4te" {
|
|
t.Errorf("state = %q, want it handed back unchanged", got)
|
|
}
|
|
return back.Query().Get("code")
|
|
}
|
|
|
|
func exchange(t *testing.T, ts *testServer, form url.Values) (*http.Response, map[string]any) {
|
|
t.Helper()
|
|
resp, err := http.PostForm(ts.URL+"/oauth/token", form)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var decoded map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&decoded)
|
|
return resp, decoded
|
|
}
|
|
|
|
func codeForm(clientID, code string) url.Values {
|
|
return url.Values{
|
|
"grant_type": {"authorization_code"}, "code": {code}, "client_id": {clientID},
|
|
"redirect_uri": {claudeCallback}, "code_verifier": {testVerifier},
|
|
}
|
|
}
|
|
|
|
func mcpInitialize(t *testing.T, ts *testServer, bearer string) *http.Response {
|
|
t.Helper()
|
|
req, _ := http.NewRequest("POST", ts.URL+"/mcp",
|
|
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if bearer != "" {
|
|
req.Header.Set("Authorization", "Bearer "+bearer)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
return resp
|
|
}
|
|
|
|
func TestMCPSignInWithTheAccount(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
// 1. The connector's first request has no credential, and the answer says
|
|
// where to read how to get one.
|
|
resp := mcpInitialize(t, ts, "")
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("unauthenticated /mcp: status %d", resp.StatusCode)
|
|
}
|
|
challenge := resp.Header.Get("WWW-Authenticate")
|
|
want := `resource_metadata="http://localhost/.well-known/oauth-protected-resource/mcp"`
|
|
if !strings.Contains(challenge, want) {
|
|
t.Fatalf("WWW-Authenticate = %q, want it to contain %s", challenge, want)
|
|
}
|
|
|
|
// 2. Discovery, both documents.
|
|
var prm, asm map[string]any
|
|
for path, into := range map[string]*map[string]any{
|
|
"/.well-known/oauth-protected-resource/mcp": &prm,
|
|
"/.well-known/oauth-authorization-server": &asm,
|
|
} {
|
|
r, err := http.Get(ts.URL + path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
json.NewDecoder(r.Body).Decode(into)
|
|
r.Body.Close()
|
|
if r.StatusCode != http.StatusOK {
|
|
t.Fatalf("%s: status %d", path, r.StatusCode)
|
|
}
|
|
}
|
|
if prm["resource"] != "http://localhost/mcp" {
|
|
t.Errorf("resource = %v, want the address a person types in", prm["resource"])
|
|
}
|
|
if asm["issuer"] != "http://localhost" || asm["registration_endpoint"] != "http://localhost/oauth/register" {
|
|
t.Errorf("authorization server metadata = %v", asm)
|
|
}
|
|
|
|
// 3. Registration, 4. the browser's visit, 5. approval, 6. the exchange.
|
|
clientID := registerClient(t, ts, claudeCallback)
|
|
query := authorizeQuery(clientID, claudeCallback)
|
|
|
|
noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}}
|
|
r, err := noFollow.Get(ts.URL + "/oauth/authorize?" + query)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Body.Close()
|
|
if loc := r.Header.Get("Location"); r.StatusCode != http.StatusFound || !strings.HasPrefix(loc, "/forbind?") {
|
|
t.Fatalf("authorize: status %d, Location %q — want the approval page", r.StatusCode, loc)
|
|
}
|
|
|
|
// The approval page's question: what is this, and where does it go.
|
|
info, body := ts.do(t, "GET", "/api/v1/oauth/request?"+query, nil)
|
|
if info.StatusCode != http.StatusOK || body["client_name"] != "Claude" || body["redirect_to"] != "claude.ai" {
|
|
t.Fatalf("request info: status %d, %v", info.StatusCode, body)
|
|
}
|
|
|
|
code := approve(t, ts, query)
|
|
tokResp, tok := exchange(t, ts, codeForm(clientID, code))
|
|
if tokResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("token: status %d, %v", tokResp.StatusCode, tok)
|
|
}
|
|
access, _ := tok["access_token"].(string)
|
|
if tok["token_type"] != "Bearer" || !strings.HasPrefix(access, "vrd_") {
|
|
t.Fatalf("token response = %v", tok)
|
|
}
|
|
if cc := tokResp.Header.Get("Cache-Control"); cc != "no-store" {
|
|
t.Errorf("Cache-Control = %q on a response carrying a token", cc)
|
|
}
|
|
|
|
// 7. The token works where the person typed the address.
|
|
if resp := mcpInitialize(t, ts, access); resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("/mcp with the token: status %d", resp.StatusCode)
|
|
}
|
|
|
|
// 8. And it is on the tokens page, under the client's name, marked as such.
|
|
_, list := ts.do(t, "GET", "/api/v1/tokens", nil)
|
|
tokens, _ := list["tokens"].([]any)
|
|
if len(tokens) != 1 {
|
|
t.Fatalf("tokens page lists %d, want 1: %v", len(tokens), list)
|
|
}
|
|
entry := tokens[0].(map[string]any)
|
|
if entry["name"] != "Claude" || entry["connected"] != true {
|
|
t.Errorf("token entry = %v", entry)
|
|
}
|
|
|
|
// 9. Revoked there, the client is told to sign in again.
|
|
if resp, _ := ts.do(t, "DELETE", "/api/v1/tokens/"+entry["id"].(string), nil); resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("revoke: status %d", resp.StatusCode)
|
|
}
|
|
resp = mcpInitialize(t, ts, access)
|
|
if resp.StatusCode != http.StatusUnauthorized || resp.Header.Get("WWW-Authenticate") == "" {
|
|
t.Errorf("revoked token: status %d, WWW-Authenticate %q", resp.StatusCode, resp.Header.Get("WWW-Authenticate"))
|
|
}
|
|
}
|
|
|
|
// A code is good once. A second exchange — a replay, or a code that leaked — gets
|
|
// nothing, and neither does a wrong verifier, the wrong client or the wrong
|
|
// redirect: each is somebody other than the one the code was issued to.
|
|
func TestOAuthCodeIsBoundAndSingleUse(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
clientID := registerClient(t, ts, claudeCallback)
|
|
other := registerClient(t, ts, claudeCallback)
|
|
query := authorizeQuery(clientID, claudeCallback)
|
|
|
|
cases := map[string]func(url.Values){
|
|
"wrong verifier": func(f url.Values) { f.Set("code_verifier", strings.Repeat("w", 43)) },
|
|
"no verifier": func(f url.Values) { f.Del("code_verifier") },
|
|
"other client": func(f url.Values) { f.Set("client_id", other) },
|
|
"other redirect": func(f url.Values) { f.Set("redirect_uri", "https://claude.ai/elsewhere") },
|
|
}
|
|
for name, spoil := range cases {
|
|
code := approve(t, ts, query)
|
|
form := codeForm(clientID, code)
|
|
spoil(form)
|
|
if resp, body := exchange(t, ts, form); resp.StatusCode != http.StatusBadRequest || body["error"] != "invalid_grant" {
|
|
t.Errorf("%s: status %d, %v — want invalid_grant", name, resp.StatusCode, body)
|
|
}
|
|
// Spent by the failed attempt, so the rightful owner cannot use it either.
|
|
if resp, _ := exchange(t, ts, codeForm(clientID, code)); resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("%s: the code survived a failed exchange", name)
|
|
}
|
|
}
|
|
|
|
code := approve(t, ts, query)
|
|
if resp, _ := exchange(t, ts, codeForm(clientID, code)); resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("first exchange: status %d", resp.StatusCode)
|
|
}
|
|
if resp, body := exchange(t, ts, codeForm(clientID, code)); resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("replayed code: status %d, %v", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// Where a code may be sent is the whole security of the approval: a client
|
|
// registers its addresses, and a request for any other is refused without
|
|
// sending anything anywhere.
|
|
func TestOAuthRedirectRules(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
for _, bad := range []string{
|
|
"javascript:alert(1)",
|
|
"data:text/html,hi",
|
|
"http://evil.example/cb", // plain http off this machine
|
|
"https://claude.ai/cb#frag",
|
|
"not a uri",
|
|
} {
|
|
resp, body := postJSON(t, ts.URL+"/oauth/register",
|
|
`{"client_name":"x","redirect_uris":[`+jsonString(bad)+`]}`)
|
|
if resp.StatusCode != http.StatusBadRequest || body["error"] != "invalid_redirect_uri" {
|
|
t.Errorf("registering %q: status %d, %v", bad, resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
clientID := registerClient(t, ts, claudeCallback)
|
|
noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}}
|
|
r, err := noFollow.Get(ts.URL + "/oauth/authorize?" + authorizeQuery(clientID, "https://evil.example/cb"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Body.Close()
|
|
if r.StatusCode != http.StatusBadRequest || r.Header.Get("Location") != "" {
|
|
t.Errorf("unregistered redirect: status %d, Location %q — want a refusal shown here",
|
|
r.StatusCode, r.Header.Get("Location"))
|
|
}
|
|
resp, _ := ts.do(t, "POST", "/api/v1/oauth/approve", map[string]any{
|
|
"query": authorizeQuery(clientID, "https://evil.example/cb"), "approve": true,
|
|
})
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("approving an unregistered redirect: status %d", resp.StatusCode)
|
|
}
|
|
|
|
// A desktop client listens on whatever port it is given that day.
|
|
loopback := registerClient(t, ts, "http://127.0.0.1/callback")
|
|
r, err = noFollow.Get(ts.URL + "/oauth/authorize?" + authorizeQuery(loopback, "http://127.0.0.1:53682/callback"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Body.Close()
|
|
if r.StatusCode != http.StatusFound || !strings.HasPrefix(r.Header.Get("Location"), "/forbind?") {
|
|
t.Errorf("loopback with a port: status %d, Location %q", r.StatusCode, r.Header.Get("Location"))
|
|
}
|
|
|
|
// No PKCE is answered to the client — its address is trusted by now.
|
|
q, _ := url.ParseQuery(authorizeQuery(clientID, claudeCallback))
|
|
q.Del("code_challenge")
|
|
r, err = noFollow.Get(ts.URL + "/oauth/authorize?" + q.Encode())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Body.Close()
|
|
if loc := r.Header.Get("Location"); !strings.HasPrefix(loc, claudeCallback+"?") || !strings.Contains(loc, "error=invalid_request") {
|
|
t.Errorf("no PKCE: Location %q", loc)
|
|
}
|
|
}
|
|
|
|
// An approval mints a token, so a token must not be able to approve: otherwise
|
|
// one leaked token is a machine for making more that survive its revocation.
|
|
func TestOAuthApprovalNeedsASession(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
var userID string
|
|
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key := ts.apiToken(t, userID)
|
|
clientID := registerClient(t, ts, claudeCallback)
|
|
|
|
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/oauth/approve",
|
|
strings.NewReader(`{"query":"`+authorizeQuery(clientID, claudeCallback)+`","approve":true}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+key)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("approval with an API token: status %d, want 403", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// "Afvis" sends the client away with access_denied and no code.
|
|
func TestOAuthDenial(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
clientID := registerClient(t, ts, claudeCallback)
|
|
|
|
_, body := ts.do(t, "POST", "/api/v1/oauth/approve", map[string]any{
|
|
"query": authorizeQuery(clientID, claudeCallback), "approve": false,
|
|
})
|
|
back, _ := url.Parse(body["redirect_to"].(string))
|
|
if back.Query().Get("error") != "access_denied" || back.Query().Get("code") != "" {
|
|
t.Errorf("denial redirect = %v", body["redirect_to"])
|
|
}
|
|
}
|
|
|
|
// Connecting the same client again replaces its token rather than adding one.
|
|
func TestOAuthReconnectReplacesTheToken(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
clientID := registerClient(t, ts, claudeCallback)
|
|
query := authorizeQuery(clientID, claudeCallback)
|
|
|
|
_, first := exchange(t, ts, codeForm(clientID, approve(t, ts, query)))
|
|
_, second := exchange(t, ts, codeForm(clientID, approve(t, ts, query)))
|
|
|
|
_, list := ts.do(t, "GET", "/api/v1/tokens", nil)
|
|
if n := len(list["tokens"].([]any)); n != 1 {
|
|
t.Errorf("tokens after reconnecting: %d, want 1", n)
|
|
}
|
|
if resp := mcpInitialize(t, ts, first["access_token"].(string)); resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("the replaced token still works: status %d", resp.StatusCode)
|
|
}
|
|
if resp := mcpInitialize(t, ts, second["access_token"].(string)); resp.StatusCode != http.StatusOK {
|
|
t.Errorf("the new token does not work: status %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func jsonString(s string) string {
|
|
b, _ := json.Marshal(s)
|
|
return string(b)
|
|
}
|