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
@@ -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