feat(mcp): OAuth so claude.ai and ChatGPT can connect as connectors
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m49s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m49s
MindSift becomes the OAuth authorization server for its own MCP endpoint: discovery (RFC 9728/8414), dynamic client registration, authorization code with PKCE (S256), rotating refresh tokens with reuse detection. The consent page lives in the app at /oauth/authorize. A grant is an api_keys entry (kind 'oauth'), so scopes, origin badges, renaming and revoking work as for keys; tokens are valid for /mcp only. Unused connections expire after OAUTH_IDLE_DAYS (default 90). Requires BASE_URL; off in demo mode. Release 0.51.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
994f6dc554
commit
75aea4a7d4
31 files changed
+1581
-49
No files matched your search
@@ -17,6 +17,8 @@ import (
|
||||
type apiKeyAuth struct {
|
||||
ID string
|
||||
Scopes []string
|
||||
// OAuth: an access token of an OAuth connection (oauth.go), valid for the MCP endpoint only
|
||||
OAuth bool
|
||||
}
|
||||
|
||||
// apiActor: an API key acting on entries; via is storage.OriginAPI or storage.OriginMCP. Recorded as
|
||||
|
||||
@@ -14,14 +14,16 @@ import (
|
||||
|
||||
// startDoneCleanupTicker runs the retention delays (storage/retention.go): completed tasks into the
|
||||
// task trash, the trashes and ticked-off list items emptied; once shortly after the start and then
|
||||
// every hour.
|
||||
// every hour. Expired and unused OAuth connections go in the same pass (oauth.go).
|
||||
func (s *Server) startDoneCleanupTicker() {
|
||||
time.Sleep(2 * time.Minute)
|
||||
s.runDoneCleanup(time.Now())
|
||||
s.runOAuthCleanup(time.Now())
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for now := range ticker.C {
|
||||
s.runDoneCleanup(now)
|
||||
s.runOAuthCleanup(now)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,13 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"demoMode": s.cfg.DemoMode,
|
||||
"sharingMode": s.store.SharingMode(),
|
||||
"passwordReset": s.passwordResetAvailable(),
|
||||
// Address to enter as connector in claude.ai / ChatGPT (OAuth); empty while OAuth is off
|
||||
"mcpConnectorUrl": func() string {
|
||||
if s.oauthEnabled() {
|
||||
return s.oauthResource()
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
// How voice input works: "server" (own Whisper), "browser" or "off"; set by STT_* only
|
||||
// browserAllowed: whether people may pick the browser's recognition instead of the Whisper server
|
||||
"voice": map[string]interface{}{"mode": s.voiceMode(), "maxSeconds": s.cfg.Speech.MaxSeconds, "browserAllowed": !s.cfg.Speech.NoBrowser},
|
||||
|
||||
@@ -93,7 +93,7 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Same checks as every other request (revoked/outdated sessions, inactive accounts)
|
||||
user, key := s.authenticateToken(token)
|
||||
if user == nil {
|
||||
if user == nil || (key != nil && key.OAuth) {
|
||||
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
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.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 }
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
const oauthTestRedirect = "https://claude.ai/api/mcp/auth_callback"
|
||||
|
||||
// oauthDo sends one request and returns status, headers and the decoded JSON body.
|
||||
func oauthDo(t *testing.T, router http.Handler, method, path, bearer, contentType, body string) (int, http.Header, map[string]interface{}) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]interface{}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec.Code, rec.Header(), out
|
||||
}
|
||||
|
||||
func pkceChallenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// The whole connection as claude.ai makes it: discovery, registration, consent, code, MCP, refresh.
|
||||
func TestOAuthFlowForMCP(t *testing.T) {
|
||||
srv, store, session := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, session)
|
||||
const jsonType = "application/json"
|
||||
const formType = "application/x-www-form-urlencoded"
|
||||
|
||||
// Without BASE_URL there is no OAuth at all
|
||||
if code, _, _ := oauthDo(t, router, http.MethodGet, "/.well-known/oauth-protected-resource", "", "", ""); code != http.StatusNotFound {
|
||||
t.Fatalf("OAuth must be off without BASE_URL, got %d", code)
|
||||
}
|
||||
if _, h, _ := oauthDo(t, router, http.MethodPost, mcpPath, "", jsonType, `{}`); h.Get("WWW-Authenticate") != "" {
|
||||
t.Fatalf("no OAuth hint without BASE_URL, got %q", h.Get("WWW-Authenticate"))
|
||||
}
|
||||
|
||||
srv.cfg.BaseURL = "https://ms.example/"
|
||||
srv.cfg.BaseURLSet = true
|
||||
router = srv.Router()
|
||||
|
||||
code, h, _ := oauthDo(t, router, http.MethodPost, mcpPath, "", jsonType, `{}`)
|
||||
if code != http.StatusUnauthorized || !strings.Contains(h.Get("WWW-Authenticate"), `resource_metadata="https://ms.example/.well-known/oauth-protected-resource"`) {
|
||||
t.Fatalf("MCP without token should point to the metadata: %d %q", code, h.Get("WWW-Authenticate"))
|
||||
}
|
||||
_, _, meta := oauthDo(t, router, http.MethodGet, "/.well-known/oauth-protected-resource/api/v1/mcp", "", "", "")
|
||||
if meta["resource"] != "https://ms.example/api/v1/mcp" {
|
||||
t.Fatalf("resource metadata: %v", meta)
|
||||
}
|
||||
_, _, meta = oauthDo(t, router, http.MethodGet, "/.well-known/oauth-authorization-server", "", "", "")
|
||||
if meta["issuer"] != "https://ms.example" || meta["token_endpoint"] != "https://ms.example/oauth/token" {
|
||||
t.Fatalf("server metadata: %v", meta)
|
||||
}
|
||||
|
||||
// Registration: only https or loopback
|
||||
if code, _, _ := oauthDo(t, router, http.MethodPost, "/oauth/register", "", jsonType, `{"client_name":"X","redirect_uris":["http://evil.example/cb"]}`); code != http.StatusBadRequest {
|
||||
t.Fatalf("plain http redirect must be refused, got %d", code)
|
||||
}
|
||||
code, _, reg := oauthDo(t, router, http.MethodPost, "/oauth/register", "", jsonType, `{"client_name":"Claude","redirect_uris":["`+oauthTestRedirect+`"],"token_endpoint_auth_method":"client_secret_post"}`)
|
||||
if code != http.StatusCreated || reg["token_endpoint_auth_method"] != "none" {
|
||||
t.Fatalf("register: %d %v", code, reg)
|
||||
}
|
||||
clientID := reg["client_id"].(string)
|
||||
|
||||
verifier := strings.Repeat("v", 50)
|
||||
params := func(redirect, challenge string) url.Values {
|
||||
return url.Values{"response_type": {"code"}, "client_id": {clientID}, "redirect_uri": {redirect},
|
||||
"code_challenge": {challenge}, "code_challenge_method": {"S256"}, "state": {"st8"}, "resource": {"https://ms.example/api/v1/mcp"}}
|
||||
}
|
||||
|
||||
// Consent page info: unknown redirect is an error shown on the page, never a redirect
|
||||
if code, _, _ := oauthDo(t, router, http.MethodGet, "/api/v1/oauth/authorize?"+params("https://evil.example/cb", pkceChallenge(verifier)).Encode(), session, "", ""); code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown redirect_uri must be refused, got %d", code)
|
||||
}
|
||||
_, _, info := oauthDo(t, router, http.MethodGet, "/api/v1/oauth/authorize?"+params(oauthTestRedirect, "").Encode(), session, "", "")
|
||||
if r, _ := info["redirect"].(string); !strings.Contains(r, "error=invalid_request") {
|
||||
t.Fatalf("missing PKCE must go back as invalid_request: %v", info)
|
||||
}
|
||||
_, _, info = oauthDo(t, router, http.MethodGet, "/api/v1/oauth/authorize?"+params(oauthTestRedirect, pkceChallenge(verifier)).Encode(), session, "", "")
|
||||
if info["clientName"] != "Claude" || info["redirectHost"] != "claude.ai" || len(info["scopes"].([]interface{})) != 4 {
|
||||
t.Fatalf("consent info: %v", info)
|
||||
}
|
||||
// API keys cannot agree on anyone's behalf
|
||||
key, _ := store.CreateAPIKey(claims.UserID, "Skript", []string{"tasks:read"})
|
||||
if code, _, _ := oauthDo(t, router, http.MethodGet, "/api/v1/oauth/authorize?"+params(oauthTestRedirect, pkceChallenge(verifier)).Encode(), key.RawKey, "", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("API key must not reach the consent endpoint, got %d", code)
|
||||
}
|
||||
|
||||
consent := func(approve bool, scopes string) string {
|
||||
t.Helper()
|
||||
body := `{"responseType":"code","clientId":"` + clientID + `","redirectUri":"` + oauthTestRedirect + `","codeChallenge":"` +
|
||||
pkceChallenge(verifier) + `","codeChallengeMethod":"S256","state":"st8","approve":` + map[bool]string{true: "true", false: "false"}[approve] +
|
||||
`,"scopes":` + scopes + `}`
|
||||
code, _, out := oauthDo(t, router, http.MethodPost, "/api/v1/oauth/authorize", session, jsonType, body)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("consent: %d %v", code, out)
|
||||
}
|
||||
return out["redirect"].(string)
|
||||
}
|
||||
if r := consent(false, `[]`); !strings.Contains(r, "error=access_denied") || !strings.Contains(r, "state=st8") {
|
||||
t.Fatalf("refusing should report access_denied with state: %s", r)
|
||||
}
|
||||
codeOf := func(redirect string) string {
|
||||
u, _ := url.Parse(redirect)
|
||||
if u.Query().Get("state") != "st8" || u.Query().Get("iss") != "https://ms.example" {
|
||||
t.Fatalf("redirect lacks state or iss: %s", redirect)
|
||||
}
|
||||
return u.Query().Get("code")
|
||||
}
|
||||
token := func(form url.Values) (int, map[string]interface{}) {
|
||||
code, _, out := oauthDo(t, router, http.MethodPost, "/oauth/token", "", formType, form.Encode())
|
||||
return code, out
|
||||
}
|
||||
exchange := func(code, redirect, verifier string) url.Values {
|
||||
return url.Values{"grant_type": {"authorization_code"}, "code": {code}, "client_id": {clientID}, "redirect_uri": {redirect}, "code_verifier": {verifier}}
|
||||
}
|
||||
|
||||
// A wrong verifier uses the code up
|
||||
c1 := codeOf(consent(true, `["tasks:read"]`))
|
||||
if code, out := token(exchange(c1, oauthTestRedirect, strings.Repeat("w", 50))); code != http.StatusBadRequest || out["error"] != "invalid_grant" {
|
||||
t.Fatalf("wrong verifier: %d %v", code, out)
|
||||
}
|
||||
if code, _ := token(exchange(c1, oauthTestRedirect, verifier)); code != http.StatusBadRequest {
|
||||
t.Fatalf("a code works only once, got %d", code)
|
||||
}
|
||||
c2 := codeOf(consent(true, `["tasks:read"]`))
|
||||
if code, _ := token(exchange(c2, "https://claude.ai/other", verifier)); code != http.StatusBadRequest {
|
||||
t.Fatalf("redirect_uri must match the code's, got %d", code)
|
||||
}
|
||||
c3 := codeOf(consent(true, `["tasks:read"]`))
|
||||
code, tok := token(exchange(c3, oauthTestRedirect, verifier))
|
||||
if code != http.StatusOK || tok["scope"] != "tasks:read" || tok["token_type"] != "Bearer" {
|
||||
t.Fatalf("token: %d %v", code, tok)
|
||||
}
|
||||
access, refresh := tok["access_token"].(string), tok["refresh_token"].(string)
|
||||
|
||||
// The access token works at MCP with exactly the agreed rights, and nowhere else
|
||||
_, res := rpc(t, router, access, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||
names := strings.Join(toolNames(res), ",")
|
||||
if !strings.Contains(names, "list_tasks") || strings.Contains(names, "complete_task") || strings.Contains(names, "get_list") {
|
||||
t.Fatalf("tools for tasks:read: %s", names)
|
||||
}
|
||||
if code, _, _ := oauthDo(t, router, http.MethodGet, "/api/v1/tasks", access, "", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("OAuth token must not reach REST, got %d", code)
|
||||
}
|
||||
if code, _, _ := oauthDo(t, router, http.MethodPost, "/api/v1/webhooks/ingest", access, jsonType, `{"content":"x"}`); code != http.StatusUnauthorized {
|
||||
t.Fatalf("OAuth token must not deliver webhooks, got %d", code)
|
||||
}
|
||||
|
||||
// Agent changes carry the connection's name, which is listed among the keys; agreeing again
|
||||
// does not add a second entry
|
||||
_, res = rpc(t, router, access, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_tasks","arguments":{}}}`)
|
||||
if _, isErr := callResult(t, res); isErr {
|
||||
t.Fatalf("list_tasks via OAuth: %v", res)
|
||||
}
|
||||
codeOf(consent(true, `["tasks:read","lists:read"]`))
|
||||
keys, _ := store.GetAPIKeys(claims.UserID)
|
||||
var grant *domain.APIKey
|
||||
for i := range keys {
|
||||
if keys[i].Kind == "oauth" {
|
||||
if grant != nil {
|
||||
t.Fatalf("consenting again must reuse the entry: %v", keys)
|
||||
}
|
||||
grant = &keys[i]
|
||||
}
|
||||
}
|
||||
if grant == nil || grant.Name != "Claude (OAuth)" || grant.Scopes != "tasks:read,lists:read" || grant.LastUsedAt == "" {
|
||||
t.Fatalf("OAuth entry: %+v", grant)
|
||||
}
|
||||
|
||||
// Refresh rotates; the old refresh token used again ends the whole connection
|
||||
code, tok2 := token(url.Values{"grant_type": {"refresh_token"}, "refresh_token": {refresh}, "client_id": {clientID}})
|
||||
if code != http.StatusOK || tok2["scope"] != "tasks:read lists:read" {
|
||||
t.Fatalf("refresh: %d %v", code, tok2)
|
||||
}
|
||||
access2 := tok2["access_token"].(string)
|
||||
if code, _ := rpc(t, router, access2, `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`); code != http.StatusOK {
|
||||
t.Fatalf("refreshed access token: %d", code)
|
||||
}
|
||||
if code, _ := token(url.Values{"grant_type": {"refresh_token"}, "refresh_token": {refresh}, "client_id": {clientID}}); code != http.StatusBadRequest {
|
||||
t.Fatalf("reused refresh token must fail, got %d", code)
|
||||
}
|
||||
if code, _ := rpc(t, router, access2, `{"jsonrpc":"2.0","id":4,"method":"tools/list"}`); code != http.StatusUnauthorized {
|
||||
t.Fatalf("reuse must end every token of the connection, got %d", code)
|
||||
}
|
||||
|
||||
// Removing the entry in the settings ends a fresh connection right away
|
||||
c4 := codeOf(consent(true, `["tasks:read"]`))
|
||||
_, tok3 := token(exchange(c4, oauthTestRedirect, verifier))
|
||||
access3 := tok3["access_token"].(string)
|
||||
if code, _, _ := oauthDo(t, router, http.MethodDelete, "/api/v1/keys/"+grant.ID, session, "", ""); code != http.StatusOK {
|
||||
t.Fatalf("delete entry: %d", code)
|
||||
}
|
||||
if code, _ := rpc(t, router, access3, `{"jsonrpc":"2.0","id":5,"method":"tools/list"}`); code != http.StatusUnauthorized {
|
||||
t.Fatalf("revoked connection must stop working, got %d", code)
|
||||
}
|
||||
|
||||
// The demo has no OAuth
|
||||
srv.cfg.DemoMode = true
|
||||
if code, _, _ := oauthDo(t, srv.Router(), http.MethodPost, "/oauth/register", "", jsonType, `{"redirect_uris":["`+oauthTestRedirect+`"]}`); code != http.StatusNotFound {
|
||||
t.Fatalf("OAuth must be off in the demo, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,14 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
|
||||
r.Get("/api/v1/events", s.handleSSE)
|
||||
|
||||
// OAuth for the MCP endpoint (oauth.go); the consent page /oauth/authorize is part of the app
|
||||
r.Get("/.well-known/oauth-protected-resource", s.oauthOnly(s.handleOAuthProtectedResource))
|
||||
r.Get("/.well-known/oauth-protected-resource/*", s.oauthOnly(s.handleOAuthProtectedResource))
|
||||
r.Get("/.well-known/oauth-authorization-server", s.oauthOnly(s.handleOAuthServerMetadata))
|
||||
r.Post("/oauth/register", s.oauthOnly(s.handleOAuthRegister))
|
||||
r.Post("/oauth/token", s.oauthOnly(s.handleOAuthToken))
|
||||
r.Post("/oauth/revoke", s.oauthOnly(s.handleOAuthRevoke))
|
||||
|
||||
// Authenticated API Routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.authMiddleware)
|
||||
@@ -304,6 +312,8 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post(mcpPath, s.handleMCP)
|
||||
r.Get(mcpPath, handleMCPNoStream)
|
||||
r.Delete(mcpPath, handleMCPNoStream)
|
||||
r.Get("/api/v1/oauth/authorize", s.oauthOnly(s.handleOAuthAuthorizeInfo))
|
||||
r.Post("/api/v1/oauth/authorize", s.oauthOnly(s.handleOAuthAuthorize))
|
||||
|
||||
// API Keys Management
|
||||
r.Get("/api/v1/keys", s.handleGetAPIKeys)
|
||||
@@ -489,6 +499,16 @@ func (s *Server) authenticateToken(token string) (user *domain.User, key *apiKey
|
||||
}
|
||||
return u, &apiKeyAuth{ID: keyID, Scopes: scopes}
|
||||
}
|
||||
if strings.HasPrefix(token, storage.OAuthAccessPrefix) {
|
||||
if !s.oauthEnabled() {
|
||||
return nil, nil
|
||||
}
|
||||
u, scopes, keyID, err := s.store.ValidateOAuthAccessToken(token, time.Now())
|
||||
if err != nil || u == nil || !u.IsActive {
|
||||
return nil, nil
|
||||
}
|
||||
return u, &apiKeyAuth{ID: keyID, Scopes: scopes, OAuth: true}
|
||||
}
|
||||
claims, err := auth.ValidateToken(s.cfg.JWTSecret, token)
|
||||
if err != nil || claims == nil {
|
||||
return nil, nil
|
||||
@@ -517,9 +537,18 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
user, key := s.authenticateToken(token)
|
||||
if user == nil {
|
||||
// Tells MCP clients where to start the OAuth flow (RFC 9728)
|
||||
if r.URL.Path == mcpPath && s.oauthEnabled() {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+s.oauthResourceMetadataURL()+`"`)
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
|
||||
return
|
||||
}
|
||||
// OAuth tokens were issued for the MCP endpoint only
|
||||
if key != nil && key.OAuth && r.URL.Path != mcpPath {
|
||||
writeError(w, http.StatusForbidden, "OAuth-Zugänge gelten nur für den MCP-Endpunkt")
|
||||
return
|
||||
}
|
||||
ctx := auth.WithUserID(r.Context(), user.ID)
|
||||
if key == nil {
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
||||
|
||||
@@ -35,6 +35,9 @@ type Config struct {
|
||||
DemoResetMinutes int
|
||||
// TrustedProxies: whose X-Forwarded-For/X-Real-IP is believed ("private", "all", "none" or CIDRs)
|
||||
TrustedProxies string
|
||||
// OAuthIdleDays: OAUTH_IDLE_DAYS, after this many unused days an OAuth connection (MCP) is
|
||||
// removed (0 = never)
|
||||
OAuthIdleDays int
|
||||
// DisableLoginProtection: LOGIN_PROTECTION=false switches off locking logins after repeated
|
||||
// failures (the zero value keeps it on)
|
||||
DisableLoginProtection bool
|
||||
@@ -259,6 +262,7 @@ func Load() *Config {
|
||||
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
|
||||
RetentionAllowNever: envBoolPtr("RETENTION_ALLOW_NEVER"),
|
||||
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
|
||||
OAuthIdleDays: envInt("OAUTH_IDLE_DAYS", 90, 0, 36500),
|
||||
SMTPHost: smtpHost,
|
||||
SMTPPort: smtpPort,
|
||||
SMTPUser: smtpUser,
|
||||
|
||||
@@ -634,6 +634,8 @@ type APIKey struct {
|
||||
Scopes string `json:"scopes"`
|
||||
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
// Kind: "key" (secret shown once) or "oauth" (a connection agreed to on the consent page)
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
type WSMessage struct {
|
||||
|
||||
@@ -59,6 +59,7 @@ func (s *Store) CreateAPIKey(userID, name string, scopes []string) (*domain.APIK
|
||||
RawKey: rawKey,
|
||||
Scopes: strings.Join(NormalizeScopes(scopes), ","),
|
||||
CreatedAt: domain.NowISO(),
|
||||
Kind: APIKeyKindKey,
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
@@ -74,7 +75,7 @@ func (s *Store) CreateAPIKey(userID, name string, scopes []string) (*domain.APIK
|
||||
|
||||
func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, name, key_prefix, scopes, last_used_at, created_at
|
||||
`SELECT id, user_id, name, key_prefix, scopes, last_used_at, created_at, kind
|
||||
FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`,
|
||||
userID,
|
||||
)
|
||||
@@ -86,7 +87,7 @@ func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) {
|
||||
keys := make([]domain.APIKey, 0)
|
||||
for rows.Next() {
|
||||
var k domain.APIKey
|
||||
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &k.Scopes, &k.LastUsedAt, &k.CreatedAt); err == nil {
|
||||
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &k.Scopes, &k.LastUsedAt, &k.CreatedAt, &k.Kind); err == nil {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
@@ -143,7 +144,7 @@ func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
|
||||
func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, string, error) {
|
||||
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
|
||||
var keyID, userID, scopes string
|
||||
err := s.db.QueryRow(`SELECT id, user_id, scopes FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID, &scopes)
|
||||
err := s.db.QueryRow(`SELECT id, user_id, scopes FROM api_keys WHERE key_hash = ? AND kind = ?`, hash, APIKeyKindKey).Scan(&keyID, &userID, &scopes)
|
||||
if err != nil {
|
||||
return nil, nil, "", errors.New("invalid API key")
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ var migrations = []migration{
|
||||
{14, "Erledigte Aufgaben aufräumen", (*Store).migrateDoneCleanup},
|
||||
{15, "Spracheingabe pro Person", (*Store).migrateVoiceInput},
|
||||
{16, "Aufbewahrung und Papierkorb für Aufgaben", (*Store).migrateRetention},
|
||||
{17, "OAuth für den MCP-Zugang", (*Store).migrateOAuth},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// OAuth for the MCP endpoint (Doku/plans/oauth-fuer-mcp.md). MindSift is its own authorization
|
||||
// server: clients register themselves (public clients, PKCE), a person agrees on the consent page,
|
||||
// and the grant becomes an entry in api_keys (kind 'oauth'). Rights, origin, "last used" and revoking
|
||||
// therefore work as for every key; the tokens hang on that entry and disappear with it. Codes and
|
||||
// tokens are stored only as SHA-256 hashes.
|
||||
|
||||
const (
|
||||
APIKeyKindKey = "key"
|
||||
APIKeyKindOAuth = "oauth"
|
||||
|
||||
OAuthAccessPrefix = "mindsift_oat_"
|
||||
OAuthRefreshPrefix = "mindsift_ort_"
|
||||
|
||||
OAuthCodeTTL = 10 * time.Minute
|
||||
OAuthAccessTTL = time.Hour
|
||||
OAuthRefreshTTL = 90 * 24 * time.Hour
|
||||
// Clients nobody agreed to within this time are removed again (registration is open to anyone)
|
||||
oauthUnusedClientTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOAuthClientNotFound = errors.New("unknown client")
|
||||
ErrOAuthInvalidGrant = errors.New("invalid grant")
|
||||
ErrOAuthTokenInvalid = errors.New("invalid token")
|
||||
)
|
||||
|
||||
type OAuthClient struct {
|
||||
ID string
|
||||
Name string
|
||||
RedirectURIs []string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// OAuthTokens: what the token endpoint hands out
|
||||
type OAuthTokens struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresIn int
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
func (s *Store) migrateOAuth() error {
|
||||
if err := s.addColumnIfMissing("api_keys", "kind", "TEXT NOT NULL DEFAULT 'key'"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.addColumnIfMissing("api_keys", "oauth_client_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.execInTx(
|
||||
stmt(`CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
redirect_uris TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`),
|
||||
stmt(`CREATE TABLE IF NOT EXISTS oauth_codes (
|
||||
code_hash TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
key_id TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_challenge TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
FOREIGN KEY(key_id) REFERENCES api_keys(id) ON DELETE CASCADE
|
||||
)`),
|
||||
stmt(`CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
key_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
replaced_at TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY(key_id) REFERENCES api_keys(id) ON DELETE CASCADE
|
||||
)`),
|
||||
stmt(`CREATE INDEX IF NOT EXISTS idx_oauth_tokens_key ON oauth_tokens(key_id)`),
|
||||
stmt(`CREATE INDEX IF NOT EXISTS idx_api_keys_oauth ON api_keys(user_id, oauth_client_id)`),
|
||||
)
|
||||
}
|
||||
|
||||
func randomToken(prefix string) string {
|
||||
buf := make([]byte, 32)
|
||||
_, _ = rand.Read(buf)
|
||||
return prefix + hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func hashOAuthSecret(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func isoTime(t time.Time) string { return t.UTC().Format(time.RFC3339) }
|
||||
|
||||
// CreateOAuthClient stores a self-registered client (RFC 7591); the redirect URIs are checked by the caller.
|
||||
func (s *Store) CreateOAuthClient(name string, redirectURIs []string, now time.Time) (*OAuthClient, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "KI-Agent"
|
||||
}
|
||||
if r := []rune(name); len(r) > 60 {
|
||||
name = string(r[:60])
|
||||
}
|
||||
uris, _ := json.Marshal(redirectURIs)
|
||||
c := &OAuthClient{ID: domain.NewID("mcl"), Name: name, RedirectURIs: redirectURIs, CreatedAt: isoTime(now)}
|
||||
_, err := s.db.Exec(`INSERT INTO oauth_clients (id, name, redirect_uris, created_at) VALUES (?, ?, ?, ?)`,
|
||||
c.ID, c.Name, string(uris), c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOAuthClient(id string) (*OAuthClient, error) {
|
||||
var c OAuthClient
|
||||
var uris string
|
||||
err := s.db.QueryRow(`SELECT id, name, redirect_uris, created_at FROM oauth_clients WHERE id = ?`, id).
|
||||
Scan(&c.ID, &c.Name, &uris, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, ErrOAuthClientNotFound
|
||||
}
|
||||
_ = json.Unmarshal([]byte(uris), &c.RedirectURIs)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// GrantOAuth records a person's consent and returns a one-time authorization code. The grant is the
|
||||
// api_keys entry of this person and client; agreeing again reuses it with the newly chosen rights, so
|
||||
// reconnecting does not pile up entries.
|
||||
func (s *Store) GrantOAuth(userID string, client *OAuthClient, scopes []string, redirectURI, codeChallenge string, now time.Time) (string, error) {
|
||||
joined := strings.Join(NormalizeScopes(scopes), ",")
|
||||
var keyID string
|
||||
err := s.db.QueryRow(`SELECT id FROM api_keys WHERE user_id = ? AND kind = ? AND oauth_client_id = ?`,
|
||||
userID, APIKeyKindOAuth, client.ID).Scan(&keyID)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
keyID = domain.NewID("key")
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO api_keys (id, user_id, name, key_prefix, key_hash, scopes, created_at, kind, oauth_client_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
// Nobody knows this secret: OAuth entries are only reachable through their tokens
|
||||
keyID, userID, client.Name+" (OAuth)", "OAuth", auth.HashAPIKey(randomToken("unused_")), joined, isoTime(now),
|
||||
APIKeyKindOAuth, client.ID)
|
||||
case err == nil:
|
||||
_, err = s.db.Exec(`UPDATE api_keys SET scopes = ? WHERE id = ?`, joined, keyID)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
code := randomToken("")
|
||||
_, err = s.db.Exec(`INSERT INTO oauth_codes (code_hash, client_id, key_id, redirect_uri, code_challenge, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, hashOAuthSecret(code), client.ID, keyID, redirectURI, codeChallenge, isoTime(now.Add(OAuthCodeTTL)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// pkceMatches: S256, base64url(SHA-256(verifier)) without padding (RFC 7636)
|
||||
func pkceMatches(verifier, challenge string) bool {
|
||||
if len(verifier) < 43 || len(verifier) > 128 {
|
||||
return false
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return subtle.ConstantTimeCompare([]byte(base64.RawURLEncoding.EncodeToString(sum[:])), []byte(challenge)) == 1
|
||||
}
|
||||
|
||||
// ExchangeOAuthCode trades an authorization code for tokens. The code works once, within its time, for
|
||||
// the client and redirect URI it was issued to and with the matching PKCE verifier.
|
||||
func (s *Store) ExchangeOAuthCode(code, clientID, redirectURI, verifier string, now time.Time) (*OAuthTokens, error) {
|
||||
var codeClient, keyID, codeRedirect, challenge, expires string
|
||||
err := s.db.QueryRow(`SELECT client_id, key_id, redirect_uri, code_challenge, expires_at FROM oauth_codes WHERE code_hash = ?`,
|
||||
hashOAuthSecret(code)).Scan(&codeClient, &keyID, &codeRedirect, &challenge, &expires)
|
||||
if err != nil {
|
||||
return nil, ErrOAuthInvalidGrant
|
||||
}
|
||||
// Used up whatever happens next; a second attempt with the same code finds nothing
|
||||
res, err := s.db.Exec(`DELETE FROM oauth_codes WHERE code_hash = ?`, hashOAuthSecret(code))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil, ErrOAuthInvalidGrant
|
||||
}
|
||||
if codeClient != clientID || codeRedirect != redirectURI || expires < isoTime(now) || !pkceMatches(verifier, challenge) {
|
||||
return nil, ErrOAuthInvalidGrant
|
||||
}
|
||||
return s.issueOAuthTokens(keyID, now)
|
||||
}
|
||||
|
||||
// RefreshOAuth hands out new tokens for a refresh token and retires the old one. A retired refresh
|
||||
// token used again means it was copied: every token of the grant ends, the client has to ask again.
|
||||
func (s *Store) RefreshOAuth(refresh, clientID string, now time.Time) (*OAuthTokens, error) {
|
||||
var keyID, expires, replaced, grantClient string
|
||||
err := s.db.QueryRow(`SELECT t.key_id, t.expires_at, t.replaced_at, k.oauth_client_id FROM oauth_tokens t
|
||||
JOIN api_keys k ON k.id = t.key_id WHERE t.token_hash = ? AND t.kind = 'refresh'`,
|
||||
hashOAuthSecret(refresh)).Scan(&keyID, &expires, &replaced, &grantClient)
|
||||
if err != nil || grantClient != clientID || expires < isoTime(now) {
|
||||
return nil, ErrOAuthInvalidGrant
|
||||
}
|
||||
if replaced != "" {
|
||||
_, _ = s.db.Exec(`DELETE FROM oauth_tokens WHERE key_id = ?`, keyID)
|
||||
return nil, ErrOAuthInvalidGrant
|
||||
}
|
||||
res, err := s.db.Exec(`UPDATE oauth_tokens SET replaced_at = ? WHERE token_hash = ? AND replaced_at = ''`,
|
||||
isoTime(now), hashOAuthSecret(refresh))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil, ErrOAuthInvalidGrant // refreshed in parallel
|
||||
}
|
||||
return s.issueOAuthTokens(keyID, now)
|
||||
}
|
||||
|
||||
func (s *Store) issueOAuthTokens(keyID string, now time.Time) (*OAuthTokens, error) {
|
||||
var scopes string
|
||||
if err := s.db.QueryRow(`SELECT scopes FROM api_keys WHERE id = ?`, keyID).Scan(&scopes); err != nil {
|
||||
return nil, ErrOAuthInvalidGrant // revoked meanwhile
|
||||
}
|
||||
t := &OAuthTokens{
|
||||
AccessToken: randomToken(OAuthAccessPrefix),
|
||||
RefreshToken: randomToken(OAuthRefreshPrefix),
|
||||
ExpiresIn: int(OAuthAccessTTL.Seconds()),
|
||||
Scopes: NormalizeScopes(strings.Split(scopes, ",")),
|
||||
}
|
||||
err := s.execInTx(
|
||||
stmt(`INSERT INTO oauth_tokens (token_hash, kind, key_id, expires_at) VALUES (?, 'access', ?, ?)`,
|
||||
hashOAuthSecret(t.AccessToken), keyID, isoTime(now.Add(OAuthAccessTTL))),
|
||||
stmt(`INSERT INTO oauth_tokens (token_hash, kind, key_id, expires_at) VALUES (?, 'refresh', ?, ?)`,
|
||||
hashOAuthSecret(t.RefreshToken), keyID, isoTime(now.Add(OAuthRefreshTTL))),
|
||||
stmt(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, isoTime(now), keyID),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ValidateOAuthAccessToken returns owner, rights and entry id of an access token, like ValidateAPIKey.
|
||||
func (s *Store) ValidateOAuthAccessToken(raw string, now time.Time) (*domain.User, []string, string, error) {
|
||||
var keyID, userID, scopes, expires string
|
||||
err := s.db.QueryRow(`SELECT k.id, k.user_id, k.scopes, t.expires_at FROM oauth_tokens t
|
||||
JOIN api_keys k ON k.id = t.key_id WHERE t.token_hash = ? AND t.kind = 'access'`,
|
||||
hashOAuthSecret(strings.TrimSpace(raw))).Scan(&keyID, &userID, &scopes, &expires)
|
||||
if err != nil || expires < isoTime(now) {
|
||||
return nil, nil, "", ErrOAuthTokenInvalid
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, isoTime(now), keyID)
|
||||
u, err := s.GetUserByID(userID)
|
||||
return u, NormalizeScopes(strings.Split(scopes, ",")), keyID, err
|
||||
}
|
||||
|
||||
// RevokeOAuthToken ends one token (RFC 7009). Revoking a refresh token ends the access tokens of the
|
||||
// grant as well; the entry itself stays until the person removes it.
|
||||
func (s *Store) RevokeOAuthToken(raw, clientID string) {
|
||||
var keyID, kind, grantClient string
|
||||
if s.db.QueryRow(`SELECT t.key_id, t.kind, k.oauth_client_id FROM oauth_tokens t JOIN api_keys k ON k.id = t.key_id
|
||||
WHERE t.token_hash = ?`, hashOAuthSecret(raw)).Scan(&keyID, &kind, &grantClient) != nil || grantClient != clientID {
|
||||
return
|
||||
}
|
||||
if kind == "refresh" {
|
||||
_, _ = s.db.Exec(`DELETE FROM oauth_tokens WHERE key_id = ?`, keyID)
|
||||
return
|
||||
}
|
||||
_, _ = s.db.Exec(`DELETE FROM oauth_tokens WHERE token_hash = ?`, hashOAuthSecret(raw))
|
||||
}
|
||||
|
||||
// PurgeOAuth removes expired codes and tokens, clients nobody agreed to and – with idleDays > 0 –
|
||||
// OAuth entries unused for that many days (their tokens go with them).
|
||||
func (s *Store) PurgeOAuth(now time.Time, idleDays int) (removedGrants int, err error) {
|
||||
if idleDays > 0 {
|
||||
cutoff := isoTime(now.AddDate(0, 0, -idleDays))
|
||||
res, err := s.db.Exec(`DELETE FROM api_keys WHERE kind = ? AND CASE WHEN last_used_at = '' THEN created_at ELSE last_used_at END < ?`,
|
||||
APIKeyKindOAuth, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
removedGrants = int(n)
|
||||
}
|
||||
// After the grants, so that a client whose last connection just went is removed in the same pass
|
||||
return removedGrants, s.execInTx(
|
||||
stmt(`DELETE FROM oauth_codes WHERE expires_at < ?`, isoTime(now)),
|
||||
stmt(`DELETE FROM oauth_tokens WHERE expires_at < ?`, isoTime(now)),
|
||||
stmt(`DELETE FROM oauth_clients WHERE created_at < ? AND id NOT IN (SELECT oauth_client_id FROM api_keys WHERE kind = ?)`,
|
||||
isoTime(now.Add(-oauthUnusedClientTTL)), APIKeyKindOAuth),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Lifetimes follow the time handed in, not the clock: code 10 minutes, access token 1 hour, unused
|
||||
// connections after the configured days.
|
||||
func TestOAuthLifetimes(t *testing.T) {
|
||||
s, err := NewStore(filepath.Join(t.TempDir(), "oauth.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
u, err := s.EnsureDefaultUser()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Date(2026, 3, 29, 1, 30, 0, 0, time.FixedZone("CET", 3600)) // around a DST switch
|
||||
client, err := s.CreateOAuthClient("Claude", []string{"https://claude.ai/cb"}, start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier := strings.Repeat("a", 43)
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
|
||||
late, _ := s.GrantOAuth(u.ID, client, []string{ScopeTasksRead}, "https://claude.ai/cb", challenge, start)
|
||||
if _, err := s.ExchangeOAuthCode(late, client.ID, "https://claude.ai/cb", verifier, start.Add(11*time.Minute)); err != ErrOAuthInvalidGrant {
|
||||
t.Fatalf("code after 11 minutes must be refused, got %v", err)
|
||||
}
|
||||
code, _ := s.GrantOAuth(u.ID, client, []string{ScopeTasksRead}, "https://claude.ai/cb", challenge, start)
|
||||
tok, err := s.ExchangeOAuthCode(code, client.ID, "https://claude.ai/cb", verifier, start.Add(9*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("exchange: %v", err)
|
||||
}
|
||||
issued := start.Add(9 * time.Minute)
|
||||
if _, _, _, err := s.ValidateOAuthAccessToken(tok.AccessToken, issued.Add(59*time.Minute)); err != nil {
|
||||
t.Fatalf("access token within the hour: %v", err)
|
||||
}
|
||||
if _, _, _, err := s.ValidateOAuthAccessToken(tok.AccessToken, issued.Add(61*time.Minute)); err == nil {
|
||||
t.Fatal("access token after an hour must be refused")
|
||||
}
|
||||
if _, err := s.RefreshOAuth(tok.RefreshToken, client.ID, issued.Add(91*24*time.Hour)); err != ErrOAuthInvalidGrant {
|
||||
t.Fatalf("refresh token after 90 days must be refused, got %v", err)
|
||||
}
|
||||
if _, err := s.RefreshOAuth(tok.RefreshToken, "mcl_other", issued.Add(time.Hour)); err != ErrOAuthInvalidGrant {
|
||||
t.Fatalf("refresh token of another client must be refused, got %v", err)
|
||||
}
|
||||
// An ordinary key's secret check never finds OAuth entries
|
||||
if _, _, _, err := s.ValidateAPIKey(tok.AccessToken); err == nil {
|
||||
t.Fatal("OAuth token must not pass as API key")
|
||||
}
|
||||
|
||||
// Last use was at issue time: 0 days keeps it, after 90 days it goes; tokens go with it
|
||||
lastUse := issued.Add(59 * time.Minute)
|
||||
if n, _ := s.PurgeOAuth(lastUse.AddDate(0, 0, 200), 0); n != 0 {
|
||||
t.Fatalf("idle days 0 must keep connections, removed %d", n)
|
||||
}
|
||||
if n, _ := s.PurgeOAuth(lastUse.AddDate(0, 0, 89), 90); n != 0 {
|
||||
t.Fatalf("used 89 days ago must stay, removed %d", n)
|
||||
}
|
||||
fresh, _ := s.GrantOAuth(u.ID, client, []string{ScopeTasksRead}, "https://claude.ai/cb", challenge, lastUse)
|
||||
if n, _ := s.PurgeOAuth(lastUse.AddDate(0, 0, 91), 90); n != 1 {
|
||||
t.Fatalf("unused for 91 days must go, removed %d", n)
|
||||
}
|
||||
if _, err := s.ExchangeOAuthCode(fresh, client.ID, "https://claude.ai/cb", verifier, lastUse); err == nil {
|
||||
t.Fatal("codes of a removed connection must be gone")
|
||||
}
|
||||
// The client without any connection left is removed a day later
|
||||
if _, err := s.GetOAuthClient(client.ID); err == nil {
|
||||
t.Fatal("unused client should have been removed with the purge")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user