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 <[email protected]>
559 lines
19 KiB
Go
559 lines
19 KiB
Go
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)
|
|
}
|