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>
128 lines
3.6 KiB
Go
128 lines
3.6 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/kristianwind/verdande/internal/auth"
|
|
)
|
|
|
|
type APIToken struct {
|
|
ID string
|
|
UserID string
|
|
Name string
|
|
Prefix string
|
|
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
|
|
// stored, so it can never be shown again — the UI has to say so at the point of
|
|
// creation rather than offering a "reveal" that cannot work.
|
|
func (db *DB) CreateAPIToken(ctx context.Context, userID, name string, expiresAt *time.Time) (string, *APIToken, error) {
|
|
token, prefix, err := auth.NewAPIToken()
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
t := &APIToken{
|
|
ID: NewID(),
|
|
UserID: userID,
|
|
Name: name,
|
|
Prefix: prefix,
|
|
CreatedAt: time.Now().UTC(),
|
|
ExpiresAt: expiresAt,
|
|
}
|
|
|
|
var expires any
|
|
if expiresAt != nil {
|
|
expires = expiresAt.Unix()
|
|
}
|
|
_, err = db.ExecContext(ctx,
|
|
`INSERT INTO api_tokens (id, user_id, name, token_hash, prefix, created_at, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
t.ID, t.UserID, t.Name, auth.HashToken(token), t.Prefix, t.CreatedAt.Unix(), expires)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return token, t, nil
|
|
}
|
|
|
|
// UserByAPIToken resolves a bearer token to its owner.
|
|
func (db *DB) UserByAPIToken(ctx context.Context, token string) (*User, error) {
|
|
var id, userID string
|
|
var expires sql.NullInt64
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT id, user_id, expires_at FROM api_tokens WHERE token_hash = ?`,
|
|
auth.HashToken(token)).Scan(&id, &userID, &expires)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if expires.Valid && time.Now().Unix() > expires.Int64 {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
// last_used_at is what lets somebody delete the token they no longer recognise
|
|
// without wondering what will break. Written at most hourly: a token driving a
|
|
// sync loop would otherwise mean a write per request.
|
|
_, _ = db.ExecContext(ctx,
|
|
`UPDATE api_tokens SET last_used_at = ?
|
|
WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`,
|
|
time.Now().Unix(), id, time.Now().Add(-time.Hour).Unix())
|
|
|
|
return db.UserByID(ctx, userID)
|
|
}
|
|
|
|
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,
|
|
oauth_client_id IS NOT NULL
|
|
FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []APIToken
|
|
for rows.Next() {
|
|
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, &t.Connected); err != nil {
|
|
return nil, err
|
|
}
|
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
|
if lastUsed.Valid {
|
|
v := time.Unix(lastUsed.Int64, 0).UTC()
|
|
t.LastUsedAt = &v
|
|
}
|
|
if expires.Valid {
|
|
v := time.Unix(expires.Int64, 0).UTC()
|
|
t.ExpiresAt = &v
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// DeleteAPIToken is scoped by user so an id from somewhere else deletes nothing.
|
|
func (db *DB) DeleteAPIToken(ctx context.Context, userID, tokenID string) error {
|
|
res, err := db.ExecContext(ctx,
|
|
`DELETE FROM api_tokens WHERE id = ? AND user_id = ?`, tokenID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|