Files
MindSift/internal/storage/oauth.go
T
sandrodachandClaude Opus 5.5 75aea4a7d4
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m49s
feat(mcp): OAuth so claude.ai and ChatGPT can connect as connectors
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>
2026-11-01 20:01:32 +01:00

303 lines
12 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 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),
)
}