feat(mcp): forbind med kontoen i stedet for med et token

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>
This commit is contained in:
kristianwind
2026-10-31 22:49:48 +01:00
co-authored by Claude Opus 5.5
parent 4a2aa81725
commit e7df876c4a
19 changed files with 1792 additions and 55 deletions
+4
View File
@@ -185,6 +185,10 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
// er værktøjslisten bagved, der er kortere.
"POST /api/v1/mcp",
"POST /mcp",
// Og at forbinde med kontoen, som hører til MCP og ikke til opgaverne.
"GET /.well-known/oauth-authorization-server",
"POST /oauth/token",
"POST /api/v1/oauth/approve",
} {
if !notes[must] {
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
+4 -5
View File
@@ -1172,17 +1172,16 @@ func TestMailAddressPrefersTheConfiguredSender(t *testing.T) {
// namespace, and a client probing it is asking a yes-or-no question.
//
// Claude asks /.well-known/oauth-authorization-server before connecting to an
// MCP server. It read the shell as "there is an authorization server here" and
// then failed trying to register with it, reporting a broken sign-in service on
// a server that has none and needs none.
// MCP server. Before that was a real route it read the shell as "there is an
// authorization server here" and failed trying to register with it. The OAuth
// paths are answered by oauth_test.go now; everything else must still be a 404.
func TestWellKnownDoesNotAnswerWithTheAppShell(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, path := range []string{
"/.well-known/oauth-authorization-server",
"/.well-known/oauth-protected-resource",
"/.well-known/openid-configuration",
"/.well-known/oauth-protected-resource/somewhere-else",
"/.well-known/anything-at-all",
} {
resp, err := ts.client.Get(ts.URL + path)
+32 -15
View File
@@ -138,26 +138,34 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response)
}
// handleMCPWithKey is the same endpoint for a client that can only be given a
// URL: `POST /mcp?key=vrd_…`.
// handleMCPWithKey is the same endpoint at the address a person types into a
// connector dialog: `POST /mcp`. Two credentials are accepted here.
//
// Claude's custom-connector dialog asks for a name, an address and — under
// advanced settings — an OAuth client id and secret. There is no field for a
// bearer token, so /api/v1/mcp cannot be configured from it however correct the
// address is. The calendar feed already carries its credential in the URL for
// the same reason, and the request logger records the path without the query, so
// the key does not end up in the log.
// `Authorization: Bearer vrd_…` is what a client sends after signing in with the
// account (oauth_handlers.go), and what anything holding a token by hand can
// send as well.
//
// The key is the *only* thing accepted here. Reading a session cookie instead
// would make this a state-changing POST outside the CSRF check — which is to say
// a cross-site request that acts as whoever is signed in.
// `?key=vrd_…` is for a client that can only be given a URL. Claude's
// custom-connector dialog has no field for a header, so before the sign-in
// existed this was the only way to configure it, and addresses made that way
// still work. The calendar feed carries its credential in the URL for the same
// reason, and the request logger records the path without the query, so the key
// does not end up in the log.
//
// A session cookie is *never* accepted here. That would make this a
// state-changing POST outside the CSRF check — a cross-site request that acts as
// whoever is signed in. A bearer header is safe where a cookie is not: a browser
// will not attach one to a cross-site request by itself.
func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")
if h := r.Header.Get("Authorization"); key == "" && strings.HasPrefix(h, "Bearer ") {
key = strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
}
if key == "" || !auth.IsAPIToken(key) {
// 401 rather than 404: unlike a task, the existence of this endpoint is
// not a secret — it is written in the documentation.
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
"add ?key=<api token> to the URL")
// not a secret — it is written in the documentation. And the header is
// what starts the sign-in: it tells the client where to read how.
s.mcpUnauthorized(w, "sign in, or add ?key=<api token> to the URL")
return
}
@@ -167,13 +175,22 @@ func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "mcp key", err)
return
}
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
// Also the answer to a token revoked under Settings, which is why the
// header is here too: the client then asks the person to sign in again.
s.mcpUnauthorized(w, "that key is not valid")
return
}
s.handleMCP(w, r.WithContext(withUser(r.Context(), user)))
}
// mcpUnauthorized is the 401 that points a client at the sign-in (RFC 9728 §5.1).
func (s *Server) mcpUnauthorized(w http.ResponseWriter, message string) {
w.Header().Set("WWW-Authenticate",
`Bearer resource_metadata="`+s.protectedResourceMetadataURL()+`"`)
writeError(w, http.StatusUnauthorized, CodeUnauthorized, message)
}
// --- tool implementations ------------------------------------------------------------
func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {
+558
View File
@@ -0,0 +1,558 @@
package httpapi
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"unicode"
"github.com/kristianwind/verdande/internal/store"
)
// Signing in to MCP with the account, rather than with a token pasted into a URL.
//
// Claude's connector dialog tries OAuth before anything else: it asks
// /.well-known for an authorization server, registers itself, sends the person
// to sign in and approve, and comes back with a code. Until this existed the
// answer was a 404, and the only way in was to make a token under Settings and
// paste it into the address — a secret copied between two programs, which is the
// step people stopped at.
//
// The shape is the smallest one the MCP authorization spec asks for:
//
// - discovery: protected-resource metadata (RFC 9728) pointing at this instance
// as its own authorization server (RFC 8414);
// - dynamic client registration (RFC 7591), public clients only — a program
// running somewhere else cannot keep a secret, so PKCE is the proof instead;
// - the authorization-code grant with S256 PKCE, and nothing else.
//
// What an approval hands out is an ordinary personal API token. That is the
// whole permission model: a token is a person, it is listed under Settings →
// Tokens next to the hand-made ones, and revoking it there disconnects the
// client. No refresh token and no expiry, because there is nothing for one to
// protect that the token page does not already — a client that is cut off is
// answered 401 and asks the person to sign in again.
const oauthCodeTTL = time.Minute
// oauthGrant is what an authorization code stands for until it is exchanged.
type oauthGrant struct {
userID string
clientID string
redirectURI string
challenge string
scope string
expires time.Time
}
// oauthCodes are held in memory, like the socket tickets: good for a minute and
// once. A restart in that minute costs one more click, which is cheaper than a
// table of short-lived secrets.
type oauthCodes struct {
mu sync.Mutex
m map[string]oauthGrant
}
func newOAuthCodes() *oauthCodes { return &oauthCodes{m: map[string]oauthGrant{}} }
func (c *oauthCodes) issue(g oauthGrant) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
code := hex.EncodeToString(raw)
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for k, v := range c.m {
if now.After(v.expires) {
delete(c.m, k)
}
}
g.expires = now.Add(oauthCodeTTL)
c.m[code] = g
return code, nil
}
// redeem spends a code. Deleted before it is checked, so a code presented twice
// fails the second time whatever the first one did — a replayed code is the
// thing PKCE and single use are both there to stop.
func (c *oauthCodes) redeem(code string) (oauthGrant, bool) {
c.mu.Lock()
defer c.mu.Unlock()
g, ok := c.m[code]
delete(c.m, code)
if !ok || time.Now().After(g.expires) {
return oauthGrant{}, false
}
return g, true
}
// --- discovery -----------------------------------------------------------------------
// mcpResource is the address the protected resource is known by: the URL a
// person types into the connector dialog.
func (s *Server) mcpResource() string { return s.cfg.BaseURL + "/mcp" }
func (s *Server) protectedResourceMetadataURL() string {
return s.cfg.BaseURL + "/.well-known/oauth-protected-resource/mcp"
}
func (s *Server) handleProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) {
openCORS(w)
writeJSON(w, http.StatusOK, map[string]any{
"resource": s.mcpResource(),
"authorization_servers": []string{s.cfg.BaseURL},
"bearer_methods_supported": []string{"header"},
"resource_name": s.cfg.ProductSlug(),
})
}
func (s *Server) handleAuthorizationServerMetadata(w http.ResponseWriter, r *http.Request) {
openCORS(w)
base := s.cfg.BaseURL
writeJSON(w, http.StatusOK, map[string]any{
"issuer": base,
"authorization_endpoint": base + "/oauth/authorize",
"token_endpoint": base + "/oauth/token",
"registration_endpoint": base + "/oauth/register",
"response_types_supported": []string{"code"},
"grant_types_supported": []string{"authorization_code"},
"code_challenge_methods_supported": []string{"S256"},
"token_endpoint_auth_methods_supported": []string{"none"},
})
}
// openCORS lets a browser-based client read these answers. Safe for exactly the
// routes it is on: none of them reads a cookie, so a cross-site caller learns
// nothing and can do nothing it could not do with curl.
func openCORS(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
}
func handleOpenPreflight(w http.ResponseWriter, r *http.Request) {
openCORS(w)
w.WriteHeader(http.StatusNoContent)
}
// oauthError is the error shape RFC 6749 §5.2 prescribes for the token and
// registration endpoints — a client library parses `error`, not our `code`.
func oauthError(w http.ResponseWriter, status int, code, description string) {
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, status, map[string]string{"error": code, "error_description": description})
}
// --- registration --------------------------------------------------------------------
type registerRequest struct {
ClientName string `json:"client_name"`
RedirectURIs []string `json:"redirect_uris"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
}
func (s *Server) handleOAuthRegister(w http.ResponseWriter, r *http.Request) {
openCORS(w)
// Not decodeJSON: a registration request carries whatever metadata the
// client library fills in — logo_uri, grant_types, scope, contacts — and
// RFC 7591 says a server ignores what it does not use. Refusing unknown
// fields here would refuse every real client.
var req registerRequest
if err := decodeLenient(r, &req); err != nil {
oauthError(w, http.StatusBadRequest, "invalid_client_metadata", "the body is not JSON")
return
}
if len(req.RedirectURIs) == 0 || len(req.RedirectURIs) > 10 {
oauthError(w, http.StatusBadRequest, "invalid_redirect_uri",
"between one and ten redirect_uris are required")
return
}
for _, u := range req.RedirectURIs {
if !validRedirectURI(u) {
oauthError(w, http.StatusBadRequest, "invalid_redirect_uri",
"redirect_uris must be https, http on a loopback address, or an app's own scheme: "+u)
return
}
}
if m := req.TokenEndpointAuthMethod; m != "" && m != "none" {
// Said rather than silently downgraded: a client that believes it holds a
// secret would send one and wonder why it was ignored.
oauthError(w, http.StatusBadRequest, "invalid_client_metadata",
"only public clients are supported; use token_endpoint_auth_method \"none\" with PKCE")
return
}
name := cleanClientName(req.ClientName)
client, err := s.db.CreateOAuthClient(r.Context(), name, req.RedirectURIs)
if err != nil {
s.internal(w, r, "register oauth client", err)
return
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusCreated, map[string]any{
"client_id": client.ID,
"client_id_issued_at": client.CreatedAt.Unix(),
"client_name": client.Name,
"redirect_uris": client.RedirectURIs,
"grant_types": []string{"authorization_code"},
"response_types": []string{"code"},
"token_endpoint_auth_method": "none",
})
}
// cleanClientName makes the self-declared name fit to show. It is shown on the
// approval screen and on the tokens page, so it is trimmed, stripped of control
// characters and capped — and it is never trusted to say who the client is: the
// approval screen also shows where the code will be sent, which a client cannot
// choose to misstate.
func cleanClientName(name string) string {
name = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, strings.TrimSpace(name))
if runes := []rune(name); len(runes) > 60 {
name = strings.TrimSpace(string(runes[:60]))
}
if name == "" {
name = "MCP-klient"
}
return name
}
// validRedirectURI is the rule for where a code may be sent.
//
// https anywhere; plain http only to this machine's loopback (RFC 8252 §7.3,
// where a desktop client listens on a port it picks at run time); and an app's
// own scheme (§7.1), which is how Cursor and its like receive one. The schemes a
// browser would *run* rather than navigate to are refused by name — the approval
// page hands the address to `location`, and `javascript:` there is script
// running on this origin.
func validRedirectURI(raw string) bool {
if len(raw) > 2000 {
return false
}
u, err := url.Parse(raw)
if err != nil || u.Fragment != "" || u.Scheme == "" {
return false
}
switch scheme := strings.ToLower(u.Scheme); scheme {
case "https":
return u.Host != ""
case "http":
return isLoopback(u.Hostname())
case "javascript", "data", "file", "vbscript", "blob", "about", "ws", "wss", "ftp":
return false
default:
// The scheme's own syntax, RFC 3986 §3.1. url.Parse is lenient about it.
for i, r := range scheme {
if !(r >= 'a' && r <= 'z' || i > 0 && (r >= '0' && r <= '9' || r == '+' || r == '-' || r == '.')) {
return false
}
}
return true
}
}
func isLoopback(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// redirectAllowed reports whether got is one of the client's registered
// addresses. Exact, except that a loopback address may differ in its port: a
// desktop client registers once and listens wherever the OS lets it next time.
func redirectAllowed(registered []string, got string) bool {
for _, want := range registered {
if want == got {
return true
}
}
g, err := url.Parse(got)
if err != nil || g.Scheme != "http" || !isLoopback(g.Hostname()) {
return false
}
for _, want := range registered {
w, err := url.Parse(want)
if err != nil || w.Scheme != "http" || !isLoopback(w.Hostname()) {
continue
}
if w.Hostname() == g.Hostname() && w.Path == g.Path && w.RawQuery == g.RawQuery {
return true
}
}
return false
}
// --- authorization -------------------------------------------------------------------
// authorizeRequest is a checked /oauth/authorize query.
type authorizeRequest struct {
client *store.OAuthClient
redirectURI string
state string
challenge string
scope string
}
// errBadClient means the client or its redirect address could not be trusted,
// so the answer must NOT be sent to that address (RFC 6749 §4.1.2.1) — it is
// shown to the person instead. Every other problem goes back to the client.
var errBadClient = errors.New("bad client")
// checkAuthorize validates a query. It runs twice — when the browser arrives,
// and again when the person presses approve — because the approval page is a
// page in the app, and what it posts back is whatever is in its address bar.
func (s *Server) checkAuthorize(r *http.Request, q url.Values) (*authorizeRequest, string, error) {
client, err := s.db.OAuthClient(r.Context(), q.Get("client_id"))
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, "this app is not registered here — remove the connection and add it again", errBadClient
}
return nil, "", err
}
redirect := q.Get("redirect_uri")
if redirect == "" && len(client.RedirectURIs) == 1 {
redirect = client.RedirectURIs[0]
}
if !redirectAllowed(client.RedirectURIs, redirect) {
return nil, "the app asked to be answered at an address it did not register", errBadClient
}
req := &authorizeRequest{
client: client, redirectURI: redirect,
state: q.Get("state"), challenge: q.Get("code_challenge"), scope: q.Get("scope"),
}
switch {
case q.Get("response_type") != "code":
return req, "unsupported_response_type", nil
case req.challenge == "" || q.Get("code_challenge_method") != "S256":
// OAuth 2.1 makes PKCE mandatory, and plain PKCE protects nothing that
// S256 does not, so it is S256 or no code.
return req, "invalid_request", nil
}
return req, "", nil
}
// redirectWith builds the address the browser is sent back to. Callers include
// `iss` (RFC 9207): it says who answered, so a client talking to several servers
// cannot be handed one server's code by another.
func (a *authorizeRequest) redirectWith(params url.Values) string {
if a.state != "" {
params.Set("state", a.state)
}
sep := "?"
if strings.Contains(a.redirectURI, "?") {
sep = "&"
}
return a.redirectURI + sep + params.Encode()
}
// handleOAuthAuthorize is where the client sends the browser. It checks the
// request and hands over to the app's approval page, which signs the person in
// first if it needs to — the same sign-in screen, passkeys and second factor
// included, rather than a second, lesser one written for OAuth.
func (s *Server) handleOAuthAuthorize(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
req, problem, err := s.checkAuthorize(r, q)
if errors.Is(err, errBadClient) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(s.cfg.ProductSlug() + ": " + problem + ".\n"))
return
}
if err != nil {
s.internal(w, r, "oauth authorize", err)
return
}
if problem != "" {
http.Redirect(w, r, req.redirectWith(url.Values{
"error": {problem}, "iss": {s.cfg.BaseURL},
}), http.StatusFound)
return
}
http.Redirect(w, r, "/forbind?"+r.URL.RawQuery, http.StatusFound)
}
// handleOAuthRequest tells the approval page what it is approving.
func (s *Server) handleOAuthRequest(w http.ResponseWriter, r *http.Request) {
req, problem, err := s.checkAuthorize(r, r.URL.Query())
if err != nil && !errors.Is(err, errBadClient) {
s.internal(w, r, "oauth request", err)
return
}
if err == nil && problem != "" {
problem = "the app's request is not valid (" + problem + ")"
}
if problem != "" {
writeError(w, http.StatusBadRequest, CodeBadRequest, problem)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"client_name": req.client.Name,
// Where the code goes, which is the part a client cannot lie about. Any
// program can register itself as "Claude"; only Claude is answered at
// claude.ai.
"redirect_to": redirectLabel(req.redirectURI),
})
}
func redirectLabel(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return raw
}
if u.Host != "" {
return u.Hostname()
}
return u.Scheme + ":"
}
type approveRequest struct {
// Query is the approval page's own query string, exactly as the client sent
// it. Posted whole rather than as fields, so this and handleOAuthAuthorize
// read one thing with one function and cannot drift apart.
Query string `json:"query"`
Approve bool `json:"approve"`
}
// handleOAuthApprove is the person's answer. Behind requireSession: an approval
// mints a token, and a token must not be able to mint another.
func (s *Server) handleOAuthApprove(w http.ResponseWriter, r *http.Request) {
var body approveRequest
if err := decodeJSON(w, r, &body); err != nil {
return
}
q, err := url.ParseQuery(strings.TrimPrefix(body.Query, "?"))
if err != nil {
writeError(w, http.StatusBadRequest, CodeBadRequest, "the request could not be read")
return
}
req, problem, err := s.checkAuthorize(r, q)
if err != nil && !errors.Is(err, errBadClient) {
s.internal(w, r, "oauth approve", err)
return
}
if err == nil && problem != "" {
problem = "the app's request is not valid (" + problem + ")"
}
if problem != "" {
writeError(w, http.StatusBadRequest, CodeBadRequest, problem)
return
}
if !body.Approve {
writeJSON(w, http.StatusOK, map[string]string{"redirect_to": req.redirectWith(url.Values{
"error": {"access_denied"}, "iss": {s.cfg.BaseURL},
})})
return
}
code, err := s.oauthCodes.issue(oauthGrant{
userID: userFrom(r.Context()).ID, clientID: req.client.ID,
redirectURI: req.redirectURI, challenge: req.challenge, scope: req.scope,
})
if err != nil {
s.internal(w, r, "oauth code", err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"redirect_to": req.redirectWith(url.Values{
"code": {code}, "iss": {s.cfg.BaseURL},
})})
}
// --- token ---------------------------------------------------------------------------
func (s *Server) handleOAuthToken(w http.ResponseWriter, r *http.Request) {
openCORS(w)
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
if err := r.ParseForm(); err != nil {
oauthError(w, http.StatusBadRequest, "invalid_request", "the body must be form-encoded")
return
}
if gt := r.PostForm.Get("grant_type"); gt != "authorization_code" {
oauthError(w, http.StatusBadRequest, "unsupported_grant_type",
"only authorization_code is supported")
return
}
clientID := r.PostForm.Get("client_id")
if id, _, ok := r.BasicAuth(); ok && clientID == "" {
clientID = id
}
grant, ok := s.oauthCodes.redeem(r.PostForm.Get("code"))
if !ok {
oauthError(w, http.StatusBadRequest, "invalid_grant", "the code is unknown, used or expired")
return
}
// Each of these is a code presented by somebody other than the one it was
// issued to, or for somewhere else. The code is already spent either way.
if clientID != grant.clientID {
oauthError(w, http.StatusBadRequest, "invalid_grant", "the code was issued to another client")
return
}
if ru := r.PostForm.Get("redirect_uri"); ru != "" && ru != grant.redirectURI {
oauthError(w, http.StatusBadRequest, "invalid_grant", "redirect_uri does not match the authorization")
return
}
if !pkceMatches(r.PostForm.Get("code_verifier"), grant.challenge) {
oauthError(w, http.StatusBadRequest, "invalid_grant", "code_verifier does not match")
return
}
client, err := s.db.OAuthClient(r.Context(), grant.clientID)
if err != nil {
oauthError(w, http.StatusBadRequest, "invalid_client", "the client is no longer registered")
return
}
token, err := s.db.CreateOAuthToken(r.Context(), grant.userID, client)
if err != nil {
s.internal(w, r, "oauth token", err)
return
}
w.Header().Set("Cache-Control", "no-store")
out := map[string]string{"access_token": token, "token_type": "Bearer"}
if grant.scope != "" {
out["scope"] = grant.scope
}
writeJSON(w, http.StatusOK, out)
}
// pkceMatches is RFC 7636 §4.6 for S256: the challenge is the unpadded base64url
// SHA-256 of the verifier.
func pkceMatches(verifier, challenge string) bool {
if len(verifier) < 43 || len(verifier) > 128 {
return false
}
sum := sha256.Sum256([]byte(verifier))
want := base64.RawURLEncoding.EncodeToString(sum[:])
return subtle.ConstantTimeCompare([]byte(want), []byte(challenge)) == 1
}
// decodeLenient reads a JSON body that may carry fields this server does not
// use. Only for the registration endpoint — see the comment there.
func decodeLenient(r *http.Request, dst any) error {
return json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(dst)
}
+398
View File
@@ -0,0 +1,398 @@
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)
}
+38 -4
View File
@@ -64,6 +64,10 @@ type Server struct {
// app_handlers.go.
wsTickets *wsTickets
// Authorization codes on their way to becoming an MCP client's token — see
// oauth_handlers.go.
oauthCodes *oauthCodes
// Password guessing is the attack this application is actually exposed to, so
// the endpoints that check a secret are limited separately and more tightly
// than the rest of the API.
@@ -73,6 +77,7 @@ type Server struct {
mailLimiter *limiter
hookLimiter *limiter
resetLimiter *limiter
oauthLimiter *limiter
// Second factors are counted against the *account*, not the address. An
// address bucket is defeated by anybody with more than one address, which is
// the ordinary case — and a six-digit code is only safe while guesses are
@@ -115,11 +120,16 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
mailLimiter: newLimiter(60, time.Minute),
hookLimiter: newLimiter(60, time.Minute),
resetLimiter: newLimiter(5, time.Hour),
// Registration and the token exchange are open to anybody who can reach
// the instance. A real client does each once per connection; thirty an
// hour is a ceiling nobody connecting meets.
oauthLimiter: newLimiter(30, time.Hour),
// Tighter than the password bucket, and for a whole hour. Somebody typing
// their own code gets it right on the first or second try; ten in an hour is
// generous for a person and hopeless for a search through a million.
totpLimiter: newLimiter(10, time.Hour),
wsTickets: newWSTickets(),
oauthCodes: newOAuthCodes(),
}
// Before the CSP, deliberately: the policy has to describe the bytes that go
// out, and in the notes edition those are the renamed ones.
@@ -259,6 +269,22 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// nothing else.
r.Post("/mcp", s.handleMCPWithKey)
// Signing in to MCP with the account: discovery, registration, approval and
// the token exchange. In both editions, like the MCP server itself. None of
// them reads a cookie — the approval does, and it lives in /api/v1 below,
// behind the session and the CSRF check like every other write a browser
// makes. See oauth_handlers.go.
r.Get("/.well-known/oauth-protected-resource", s.handleProtectedResourceMetadata)
r.Get("/.well-known/oauth-protected-resource/mcp", s.handleProtectedResourceMetadata)
r.Get("/.well-known/oauth-authorization-server", s.handleAuthorizationServerMetadata)
r.Get("/oauth/authorize", s.handleOAuthAuthorize)
r.Options("/oauth/register", handleOpenPreflight)
r.Options("/oauth/token", handleOpenPreflight)
r.Method(http.MethodPost, "/oauth/register",
s.rateLimit(s.oauthLimiter, http.HandlerFunc(s.handleOAuthRegister)))
r.Method(http.MethodPost, "/oauth/token",
s.rateLimit(s.oauthLimiter, http.HandlerFunc(s.handleOAuthToken)))
// Google sends the browser here after consent. Outside /api/v1 because it is a
// top-level navigation rather than a fetch, but still behind the session — the
// tokens it exchanges belong to whoever is signed in.
@@ -458,6 +484,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Delete("/{tokenID}", s.handleDeleteAPIToken)
})
// The approval page's two questions — what am I approving, and yes or
// no. Behind requireSession for the reason the tokens are: an approval
// mints a token.
r.Route("/oauth", func(r chi.Router) {
r.Use(s.requireSession)
r.Get("/request", s.handleOAuthRequest)
r.Post("/approve", s.handleOAuthApprove)
})
r.Route("/ai", func(r chi.Router) {
r.Get("/settings", s.handleGetAISettings)
r.Put("/settings", s.handleSetAISettings)
@@ -805,10 +840,9 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
//
// That is not hypothetical. Claude probes
// /.well-known/oauth-authorization-server before connecting to an MCP server,
// read 200 and a page of HTML as "there is an authorization server here", and
// then failed trying to register with it — reporting a broken sign-in service
// on a server that has none and needs none. A 404 means "no OAuth here", the
// client stops asking, and the key in the URL is used as intended.
// and before that path was a real route it read 200 and a page of HTML as
// "there is an authorization server here" and failed trying to register with
// it. The OAuth paths are routes now; everything else under here is a 404.
if strings.HasPrefix(r.URL.Path, "/.well-known/") {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
+4
View File
@@ -23,6 +23,9 @@ type apiTokenJSON struct {
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
// Connected is true for a token an app received by signing in (OAuth),
// rather than one made on this page.
Connected bool `json:"connected,omitempty"`
}
// createdTokenJSON is the only response that carries the token itself, and only
@@ -45,6 +48,7 @@ func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
j := apiTokenJSON{
ID: t.ID, Name: t.Name, Prefix: t.Prefix,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
Connected: t.Connected,
}
if t.LastUsedAt != nil {
j.LastUsedAt = t.LastUsedAt.Format(time.RFC3339)