Files
MindSift/internal/api/oauth.go
T
sandrodachandClaude Opus 5.5 a8f6c1b97b feat(mcp): agents can list, read and create notes
New scopes notes:read (list_notes, get_note, notes in search) and
notes:create (create_note from Markdown). MCP only, REST stays closed;
new notes go into shared folders only with write access.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:18:26 +01:00

371 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
// OAuth for the MCP endpoint (Doku/plans/oauth-fuer-mcp.md), so that claude.ai, ChatGPT and other
// clients that cannot send an API key header can connect. MindSift is the authorization server for
// its own MCP endpoint: discovery (RFC 9728, RFC 8414), dynamic client registration (RFC 7591),
// authorization code with PKCE (S256 only, public clients) and rotating refresh tokens. The consent
// page is part of the app (/oauth/authorize), because the login lives in the browser's storage, not
// in a cookie the server could read. A grant is an api_keys entry (storage/oauth.go).
//
// Only with BASE_URL: it is the issuer and the resource, and building them from the Host header
// would let anyone choose them. Off in the demo.
const (
limitOAuthRegisterPerIP = 20
oauthMaxRedirectURIs = 10
)
// Rights offered on the consent page; the narrow "create only" ones stay with ordinary keys.
var oauthOfferedScopes = []string{storage.ScopeTasksRead, storage.ScopeTasksWrite, storage.ScopeListsRead, storage.ScopeListsWrite,
storage.ScopeNotesRead, storage.ScopeNotesCreate, storage.ScopeTriage}
// Preselected when the client asks for nothing in particular (claude.ai usually does not)
var oauthDefaultScopes = []string{storage.ScopeTasksRead, storage.ScopeTasksWrite, storage.ScopeListsRead, storage.ScopeListsWrite}
func (s *Server) oauthEnabled() bool { return s.cfg.BaseURLSet && !s.cfg.DemoMode && s.mcpEnabled() }
func (s *Server) oauthIssuer() string { return strings.TrimRight(s.cfg.BaseURL, "/") }
func (s *Server) oauthResource() string { return s.oauthIssuer() + mcpPath }
func (s *Server) oauthResourceMetadataURL() string {
return s.oauthIssuer() + "/.well-known/oauth-protected-resource"
}
// oauthOnly answers 404 while OAuth is off, as if the endpoint did not exist.
func (s *Server) oauthOnly(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.oauthEnabled() {
http.NotFound(w, r)
return
}
next(w, r)
}
}
// writeOAuthError: error body of the OAuth endpoints (RFC 6749 5.2)
func writeOAuthError(w http.ResponseWriter, status int, code, description string) {
writeJSON(w, status, map[string]string{"error": code, "error_description": description})
}
func (s *Server) handleOAuthProtectedResource(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"resource": s.oauthResource(),
"authorization_servers": []string{s.oauthIssuer()},
"scopes_supported": oauthOfferedScopes,
"bearer_methods_supported": []string{"header"},
"resource_name": "MindSift",
})
}
func (s *Server) handleOAuthServerMetadata(w http.ResponseWriter, r *http.Request) {
issuer := s.oauthIssuer()
writeJSON(w, http.StatusOK, map[string]interface{}{
"issuer": issuer,
"authorization_endpoint": issuer + "/oauth/authorize",
"token_endpoint": issuer + "/oauth/token",
"registration_endpoint": issuer + "/oauth/register",
"revocation_endpoint": issuer + "/oauth/revoke",
"response_types_supported": []string{"code"},
"grant_types_supported": []string{"authorization_code", "refresh_token"},
"code_challenge_methods_supported": []string{"S256"},
"token_endpoint_auth_methods_supported": []string{"none"},
"revocation_endpoint_auth_methods_supported": []string{"none"},
"scopes_supported": oauthOfferedScopes,
"authorization_response_iss_parameter_supported": true,
})
}
// validRedirectURI: https anywhere, plain http only back to this computer (desktop clients); no
// fragment (RFC 6749 3.1.2).
func validRedirectURI(raw string) bool {
u, err := url.Parse(raw)
if err != nil || u.Host == "" || u.Fragment != "" || u.User != nil {
return false
}
switch u.Scheme {
case "https":
return true
case "http":
h := u.Hostname()
return h == "localhost" || h == "127.0.0.1" || h == "::1"
}
return false
}
// handleOAuthRegister: POST /oauth/register (RFC 7591). Every client is public and authenticates
// with PKCE only, so a requested client secret method is answered with "none".
func (s *Server) handleOAuthRegister(w http.ResponseWriter, r *http.Request) {
if wait := s.loginGuard.throttle("oauth-register", clientIPFrom(r), limitOAuthRegisterPerIP); wait > 0 {
writeThrottled(w, wait)
return
}
var req struct {
ClientName string `json:"client_name"`
RedirectURIs []string `json:"redirect_uris"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_client_metadata", "Ungültiges JSON")
return
}
if len(req.RedirectURIs) == 0 || len(req.RedirectURIs) > oauthMaxRedirectURIs {
writeOAuthError(w, http.StatusBadRequest, "invalid_redirect_uri", "redirect_uris fehlt oder enthält zu viele Einträge")
return
}
for _, u := range req.RedirectURIs {
if !validRedirectURI(u) {
writeOAuthError(w, http.StatusBadRequest, "invalid_redirect_uri", "Nur https oder http://localhost erlaubt: "+u)
return
}
}
client, err := s.store.CreateOAuthClient(req.ClientName, req.RedirectURIs, time.Now())
if err != nil {
writeOAuthError(w, http.StatusInternalServerError, "server_error", err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"client_id": client.ID,
"client_id_issued_at": time.Now().Unix(),
"client_name": client.Name,
"redirect_uris": client.RedirectURIs,
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"token_endpoint_auth_method": "none",
})
}
// oauthAuthorizeParams: the query of /oauth/authorize, handed on by the consent page
type oauthAuthorizeParams struct {
ResponseType string `json:"responseType"`
ClientID string `json:"clientId"`
RedirectURI string `json:"redirectUri"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
State string `json:"state"`
Scope string `json:"scope"`
Resource string `json:"resource"`
}
func oauthParamsFromQuery(q url.Values) oauthAuthorizeParams {
return oauthAuthorizeParams{
ResponseType: q.Get("response_type"),
ClientID: q.Get("client_id"),
RedirectURI: q.Get("redirect_uri"),
CodeChallenge: q.Get("code_challenge"),
CodeChallengeMethod: q.Get("code_challenge_method"),
State: q.Get("state"),
Scope: q.Get("scope"),
Resource: q.Get("resource"),
}
}
var errOAuthNoRedirect = errors.New("Die Anwendung oder ihre Rücksprungadresse ist MindSift nicht bekannt. Bitte starte die Verbindung in der Anwendung neu.")
// checkAuthorize validates an authorization request. Without a known client and redirect URI there is
// nowhere safe to send the person back (err); every other problem goes back to the client as
// errRedirect (RFC 6749 4.1.2.1).
func (s *Server) checkAuthorize(p oauthAuthorizeParams) (client *storage.OAuthClient, errRedirect string, err error) {
client, err = s.store.GetOAuthClient(p.ClientID)
if err != nil {
return nil, "", errOAuthNoRedirect
}
known := false
for _, u := range client.RedirectURIs {
known = known || u == p.RedirectURI
}
if !known {
return nil, "", errOAuthNoRedirect
}
switch {
case p.ResponseType != "code":
return client, s.oauthRedirect(p, url.Values{"error": {"unsupported_response_type"}}), nil
case p.CodeChallenge == "" || p.CodeChallengeMethod != "S256":
return client, s.oauthRedirect(p, url.Values{"error": {"invalid_request"}, "error_description": {"PKCE (S256) ist erforderlich"}}), nil
case p.Resource != "" && strings.TrimRight(p.Resource, "/") != s.oauthResource() && strings.TrimRight(p.Resource, "/") != s.oauthIssuer():
return client, s.oauthRedirect(p, url.Values{"error": {"invalid_target"}}), nil
}
return client, "", nil
}
// oauthRedirect: the client's redirect URI with the answer, its state and the issuer (RFC 9207)
func (s *Server) oauthRedirect(p oauthAuthorizeParams, values url.Values) string {
u, _ := url.Parse(p.RedirectURI)
q := u.Query()
for k, v := range values {
q[k] = v
}
if p.State != "" {
q.Set("state", p.State)
}
q.Set("iss", s.oauthIssuer())
u.RawQuery = q.Encode()
return u.String()
}
// oauthScopes keeps the offered rights out of a list, in display order.
func oauthScopes(requested []string) []string {
want := map[string]bool{}
for _, sc := range requested {
want[strings.TrimSpace(sc)] = true
}
out := []string{}
for _, sc := range oauthOfferedScopes {
if want[sc] {
out = append(out, sc)
}
}
return out
}
func redirectHost(raw string) string {
if u, err := url.Parse(raw); err == nil {
return u.Host
}
return raw
}
// handleOAuthAuthorizeInfo: GET /api/v1/oauth/authorize?<query of /oauth/authorize> – what the consent
// page shows. Only for a login (API keys are not allowed here, see keyRoutes).
func (s *Server) handleOAuthAuthorizeInfo(w http.ResponseWriter, r *http.Request) {
p := oauthParamsFromQuery(r.URL.Query())
client, errRedirect, err := s.checkAuthorize(p)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if errRedirect != "" {
writeJSON(w, http.StatusOK, map[string]string{"redirect": errRedirect})
return
}
scopes := oauthScopes(strings.Fields(p.Scope))
if len(scopes) == 0 {
scopes = oauthDefaultScopes
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"clientName": client.Name,
"redirectHost": redirectHost(p.RedirectURI),
"scopes": scopes,
"offeredScopes": oauthOfferedScopes,
})
}
// handleOAuthAuthorize: POST /api/v1/oauth/authorize – the person allows (with the chosen rights) or
// refuses; the answer is the URL the consent page sends the browser to.
func (s *Server) handleOAuthAuthorize(w http.ResponseWriter, r *http.Request) {
var req struct {
oauthAuthorizeParams
Approve bool `json:"approve"`
Scopes []string `json:"scopes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
p := req.oauthAuthorizeParams
client, errRedirect, err := s.checkAuthorize(p)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if errRedirect != "" {
writeJSON(w, http.StatusOK, map[string]string{"redirect": errRedirect})
return
}
if !req.Approve {
writeJSON(w, http.StatusOK, map[string]string{"redirect": s.oauthRedirect(p, url.Values{"error": {"access_denied"}})})
return
}
scopes := oauthScopes(req.Scopes)
if len(scopes) == 0 {
writeError(w, http.StatusBadRequest, "Wähle mindestens ein Recht aus.")
return
}
code, err := s.store.GrantOAuth(auth.GetUserID(r.Context()), client, scopes, p.RedirectURI, p.CodeChallenge, time.Now())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"redirect": s.oauthRedirect(p, url.Values{"code": {code}})})
}
// oauthClientID: public clients send client_id in the form; some use HTTP Basic with an empty secret.
func oauthClientID(r *http.Request) string {
if id := r.PostForm.Get("client_id"); id != "" {
return id
}
id, _, _ := r.BasicAuth()
return id
}
// handleOAuthToken: POST /oauth/token (form-encoded) – authorization_code and refresh_token.
func (s *Server) handleOAuthToken(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "Ungültige Anfrage")
return
}
clientID := oauthClientID(r)
var tokens *storage.OAuthTokens
var err error
switch r.PostForm.Get("grant_type") {
case "authorization_code":
tokens, err = s.store.ExchangeOAuthCode(r.PostForm.Get("code"), clientID, r.PostForm.Get("redirect_uri"),
r.PostForm.Get("code_verifier"), time.Now())
case "refresh_token":
tokens, err = s.store.RefreshOAuth(r.PostForm.Get("refresh_token"), clientID, time.Now())
default:
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type", "Nur authorization_code und refresh_token")
return
}
if errors.Is(err, storage.ErrOAuthInvalidGrant) {
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "Code oder Token ungültig, abgelaufen oder widerrufen")
return
}
if err != nil {
writeOAuthError(w, http.StatusInternalServerError, "server_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"access_token": tokens.AccessToken,
"token_type": "Bearer",
"expires_in": tokens.ExpiresIn,
"refresh_token": tokens.RefreshToken,
"scope": strings.Join(tokens.Scopes, " "),
})
}
// handleOAuthRevoke: POST /oauth/revoke (RFC 7009); answers 200 even for unknown tokens.
func (s *Server) handleOAuthRevoke(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "Ungültige Anfrage")
return
}
s.store.RevokeOAuthToken(r.PostForm.Get("token"), oauthClientID(r))
w.WriteHeader(http.StatusOK)
}
// runOAuthCleanup: expired codes and tokens, abandoned registrations and – after OAUTH_IDLE_DAYS –
// connections nobody used.
func (s *Server) runOAuthCleanup(now time.Time) {
n, err := s.store.PurgeOAuth(now, s.cfg.OAuthIdleDays)
if err != nil {
slog.Error("Aufräumen der OAuth-Zugänge fehlgeschlagen", "error", err)
return
}
if n > 0 {
slog.Info("Unbenutzte OAuth-Zugänge entfernt", "anzahl", n, "tage", s.cfg.OAuthIdleDays)
}
}