feat(mcp): forbind med kontoen i stedet for med et token

Claude's forbindelsesdialog prøver OAuth først. Indtil nu var svaret 404,
og eneste vej ind var at lave et token og sætte det ind i en adresse. Nu
er instansen sin egen autorisationsserver for MCP-klienter:

- /.well-known/oauth-protected-resource(/mcp) og
  /.well-known/oauth-authorization-server peger på login'et; en 401 fra
  /mcp bærer WWW-Authenticate med resource_metadata
- /oauth/register (RFC 7591), kun offentlige klienter; registreringer,
  ingen godkender, ryddes efter et døgn
- /oauth/authorize sender til /forbind, som logger ind med det almindelige
  login og spørger "Claude vil forbinde til din konto" — og viser, hvor
  adgangen sendes hen, fordi navnet er klientens eget
- /oauth/token: authorization code + S256 PKCE, koden gælder ét minut og
  én gang; ud kommer et almindeligt API-token, listet under Tokens med
  klientens navn, og en ny forbindelse fra samme klient erstatter det
- /mcp tager nu også Authorization: Bearer; ?key= virker stadig

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-10-31 22:49:48 +01:00
co-authored by Claude Opus 5.5
parent 4a2aa81725
commit e7df876c4a
19 changed files with 1792 additions and 55 deletions
+6 -2
View File
@@ -17,6 +17,9 @@ type APIToken struct {
CreatedAt time.Time
LastUsedAt *time.Time
ExpiresAt *time.Time
// Connected marks a token an OAuth approval minted rather than one the
// person made by hand — it carries the client's name, not one they chose.
Connected bool
}
// CreateAPIToken returns the token in plaintext exactly once. Only its hash is
@@ -80,7 +83,8 @@ func (db *DB) UserByAPIToken(ctx context.Context, token string) (*User, error) {
func (db *DB) ListAPITokens(ctx context.Context, userID string) ([]APIToken, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, user_id, name, prefix, created_at, last_used_at, expires_at
`SELECT id, user_id, name, prefix, created_at, last_used_at, expires_at,
oauth_client_id IS NOT NULL
FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
@@ -92,7 +96,7 @@ func (db *DB) ListAPITokens(ctx context.Context, userID string) ([]APIToken, err
var t APIToken
var created int64
var lastUsed, expires sql.NullInt64
if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &created, &lastUsed, &expires); err != nil {
if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &created, &lastUsed, &expires, &t.Connected); err != nil {
return nil, err
}
t.CreatedAt = time.Unix(created, 0).UTC()
@@ -0,0 +1,29 @@
-- At forbinde Claude med sin konto i stedet for med et token, man selv har lavet.
--
-- Claude's forbindelsesdialog prøver OAuth, før den gør noget andet, og indtil nu
-- var svaret "det findes ikke her" — så den eneste vej ind var at lave et token i
-- indstillingerne og sætte det ind i en adresse. Det virker, men det er en
-- hemmelighed, man skal kopiere mellem to programmer, og det er præcis det trin,
-- folk går i stå på.
--
-- En klient registrerer sig selv (RFC 7591) og får et id. Ingen hemmelighed: en
-- klient, der kører hos en anden, kan ikke holde på en, så det er PKCE, der beviser,
-- at det er den samme, der kommer tilbage med koden.
CREATE TABLE oauth_clients (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
-- JSON-liste. Kun adresser, klienten selv opgav ved registreringen, kan få en
-- kode sendt hen til sig — ellers er "godkend" en knap, der sender adgangen til
-- hvem som helst.
redirect_uris TEXT NOT NULL,
created_at INTEGER NOT NULL
);
-- Det, en godkendelse udsteder, er et almindeligt API-token. Det står derfor på
-- siden med tokens, kan tilbagekaldes der, og når alle de steder, et token
-- allerede gælder. Kolonnen siger, hvilken klient det hører til, så en ny
-- godkendelse fra den samme klient erstatter den gamle frem for at lægge sig ved
-- siden af.
ALTER TABLE api_tokens ADD COLUMN oauth_client_id TEXT REFERENCES oauth_clients (id) ON DELETE CASCADE;
CREATE INDEX idx_api_tokens_oauth_client ON api_tokens (oauth_client_id) WHERE oauth_client_id IS NOT NULL;
+99
View File
@@ -0,0 +1,99 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"github.com/kristianwind/verdande/internal/auth"
)
// OAuthClient is a program that registered itself to connect to an account: in
// practice an MCP client such as Claude. It holds no secret — see migration 0038.
type OAuthClient struct {
ID string
Name string
RedirectURIs []string
CreatedAt time.Time
}
// unusedClientAge is how long a registration may sit without ever being
// approved before it is cleared. Registration is open to anybody who can reach
// the instance, so without this the table grows with every abandoned attempt.
const unusedClientAge = 24 * time.Hour
func (db *DB) CreateOAuthClient(ctx context.Context, name string, redirectURIs []string) (*OAuthClient, error) {
uris, err := json.Marshal(redirectURIs)
if err != nil {
return nil, err
}
c := &OAuthClient{ID: NewID(), Name: name, RedirectURIs: redirectURIs, CreatedAt: time.Now().UTC()}
// Swept on the way in, like the socket tickets: the table is bounded by a
// day of asking, and nothing has to remember to run a job for it.
_, _ = db.ExecContext(ctx,
`DELETE FROM oauth_clients WHERE created_at < ?
AND NOT EXISTS (SELECT 1 FROM api_tokens WHERE oauth_client_id = oauth_clients.id)`,
time.Now().Add(-unusedClientAge).Unix())
_, err = db.ExecContext(ctx,
`INSERT INTO oauth_clients (id, name, redirect_uris, created_at) VALUES (?, ?, ?, ?)`,
c.ID, c.Name, string(uris), c.CreatedAt.Unix())
if err != nil {
return nil, err
}
return c, nil
}
func (db *DB) OAuthClient(ctx context.Context, id string) (*OAuthClient, error) {
var c OAuthClient
var uris string
var created int64
err := db.QueryRowContext(ctx,
`SELECT id, name, redirect_uris, created_at FROM oauth_clients WHERE id = ?`, id).
Scan(&c.ID, &c.Name, &uris, &created)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(uris), &c.RedirectURIs); err != nil {
return nil, err
}
c.CreatedAt = time.Unix(created, 0).UTC()
return &c, nil
}
// CreateOAuthToken mints the API token an approval hands to a client.
//
// Any earlier token this person gave the same client is deleted in the same
// transaction. A client that connects again — after a reinstall, or because
// somebody pressed "connect" twice — then has one entry on the tokens page
// rather than a growing column of identical ones, each still valid.
func (db *DB) CreateOAuthToken(ctx context.Context, userID string, client *OAuthClient) (string, error) {
token, prefix, err := auth.NewAPIToken()
if err != nil {
return "", err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM api_tokens WHERE user_id = ? AND oauth_client_id = ?`, userID, client.ID); err != nil {
return "", err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO api_tokens (id, user_id, name, token_hash, prefix, created_at, oauth_client_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
NewID(), userID, client.Name, auth.HashToken(token), prefix, time.Now().Unix(), client.ID); err != nil {
return "", err
}
return token, tx.Commit()
}