Files
synapbus/internal/auth/client_store.go
T
Algis DumbrisandClaude Opus 4.6 2575ce2626 feat: OAuth 2.1 with PKCE, MCP auth, dead letters, channel management, and UX polish
- Add OAuth 2.1 identity provider with PKCE S256 (ory/fosite)
- Add RFC 7591 dynamic client registration for MCP clients
- Add RFC 8414 OAuth metadata discovery endpoint
- Add branded OAuth login/authorize pages with SynapBus design
- Add SYNAPBUS_BASE_URL env var for remote/LAN deployments
- Add OAuth bearer token authentication for MCP connections
- Add dead letter queue with Web UI management page
- Add channel leave, member list, and improved channel management
- Add agent auth middleware for MCP-authenticated requests
- Add console printer for structured server startup output
- Hide human accounts from agent management UI
- Fix SSE through middleware (Flush/Unwrap support)
- Fix graceful shutdown by closing SSE clients before server stop
- Fix localhost/127.0.0.1 redirect URI normalization for OAuth
- Remove agent self-registration MCP tools (manage via Web UI only)
- Update README with OAuth setup guide and MCP client config example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 12:54:02 +02:00

256 lines
7.5 KiB
Go

package auth
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// ClientStore defines the storage interface for OAuth client operations.
type ClientStore interface {
CreateClient(ctx context.Context, name string, redirectURIs, grantTypes, scopes []string, ownerID int64) (*OAuthClient, string, error)
CreatePublicClient(ctx context.Context, name string, redirectURIs, grantTypes, scopes []string) (*OAuthClient, error)
GetClient(ctx context.Context, clientID string) (*OAuthClient, error)
ListClientsByOwner(ctx context.Context, ownerID int64) ([]*OAuthClient, error)
VerifyClientSecret(ctx context.Context, clientID, secret string) (*OAuthClient, error)
}
// SQLiteClientStore implements ClientStore using SQLite.
type SQLiteClientStore struct {
db *sql.DB
bcryptCost int
}
// NewSQLiteClientStore creates a new SQLite-backed client store.
func NewSQLiteClientStore(db *sql.DB, bcryptCost int) *SQLiteClientStore {
if bcryptCost < 10 {
bcryptCost = 12
}
return &SQLiteClientStore{db: db, bcryptCost: bcryptCost}
}
// CreateClient registers a new OAuth client, generating client_id and client_secret.
// Returns the client and the raw secret (shown once).
func (s *SQLiteClientStore) CreateClient(ctx context.Context, name string, redirectURIs, grantTypes, scopes []string, ownerID int64) (*OAuthClient, string, error) {
clientID, err := generateClientID()
if err != nil {
return nil, "", fmt.Errorf("generate client_id: %w", err)
}
secret, err := generateClientSecret()
if err != nil {
return nil, "", fmt.Errorf("generate client_secret: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(secret), s.bcryptCost)
if err != nil {
return nil, "", fmt.Errorf("hash secret: %w", err)
}
if redirectURIs == nil {
redirectURIs = []string{}
}
if grantTypes == nil {
grantTypes = []string{"client_credentials"}
}
if scopes == nil {
scopes = []string{}
}
redirectURIsJSON, _ := json.Marshal(redirectURIs)
grantTypesJSON, _ := json.Marshal(grantTypes)
scopesJSON, _ := json.Marshal(scopes)
_, err = s.db.ExecContext(ctx,
`INSERT INTO oauth_clients (id, secret_hash, name, redirect_uris, grant_types, scopes, owner_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
clientID, string(hash), name, string(redirectURIsJSON), string(grantTypesJSON), string(scopesJSON), ownerID,
)
if err != nil {
return nil, "", fmt.Errorf("insert client: %w", err)
}
client := &OAuthClient{
ID: clientID,
SecretHash: string(hash),
Name: name,
RedirectURIs: redirectURIs,
GrantTypes: grantTypes,
Scopes: scopes,
OwnerID: ownerID,
CreatedAt: time.Now(),
}
return client, secret, nil
}
// CreatePublicClient registers a public OAuth client (no secret) for dynamic registration (RFC 7591).
func (s *SQLiteClientStore) CreatePublicClient(ctx context.Context, name string, redirectURIs, grantTypes, scopes []string) (*OAuthClient, error) {
clientID, err := generateClientID()
if err != nil {
return nil, fmt.Errorf("generate client_id: %w", err)
}
if redirectURIs == nil {
redirectURIs = []string{}
}
if grantTypes == nil {
grantTypes = []string{"authorization_code", "refresh_token"}
}
if scopes == nil {
scopes = []string{"mcp"}
}
redirectURIsJSON, _ := json.Marshal(redirectURIs)
grantTypesJSON, _ := json.Marshal(grantTypes)
scopesJSON, _ := json.Marshal(scopes)
_, err = s.db.ExecContext(ctx,
`INSERT INTO oauth_clients (id, secret_hash, name, redirect_uris, grant_types, scopes, owner_id, created_at)
VALUES (?, '', ?, ?, ?, ?, NULL, CURRENT_TIMESTAMP)`,
clientID, name, string(redirectURIsJSON), string(grantTypesJSON), string(scopesJSON),
)
if err != nil {
return nil, fmt.Errorf("insert client: %w", err)
}
return &OAuthClient{
ID: clientID,
Name: name,
RedirectURIs: redirectURIs,
GrantTypes: grantTypes,
Scopes: scopes,
CreatedAt: time.Now(),
}, nil
}
// GetClient retrieves an OAuth client by its ID.
func (s *SQLiteClientStore) GetClient(ctx context.Context, clientID string) (*OAuthClient, error) {
var redirectURIsJSON, grantTypesJSON, scopesJSON string
var ownerID sql.NullInt64
client := &OAuthClient{}
err := s.db.QueryRowContext(ctx,
`SELECT id, secret_hash, name, redirect_uris, grant_types, scopes, owner_id, created_at
FROM oauth_clients WHERE id = ?`, clientID,
).Scan(&client.ID, &client.SecretHash, &client.Name,
&redirectURIsJSON, &grantTypesJSON, &scopesJSON, &ownerID, &client.CreatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrClientNotFound
}
return nil, fmt.Errorf("query client: %w", err)
}
json.Unmarshal([]byte(redirectURIsJSON), &client.RedirectURIs)
json.Unmarshal([]byte(grantTypesJSON), &client.GrantTypes)
json.Unmarshal([]byte(scopesJSON), &client.Scopes)
if ownerID.Valid {
client.OwnerID = ownerID.Int64
}
if client.RedirectURIs == nil {
client.RedirectURIs = []string{}
}
if client.GrantTypes == nil {
client.GrantTypes = []string{}
}
if client.Scopes == nil {
client.Scopes = []string{}
}
return client, nil
}
// ListClientsByOwner returns all OAuth clients owned by the given user.
func (s *SQLiteClientStore) ListClientsByOwner(ctx context.Context, ownerID int64) ([]*OAuthClient, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, secret_hash, name, redirect_uris, grant_types, scopes, owner_id, created_at
FROM oauth_clients WHERE owner_id = ? ORDER BY name`, ownerID,
)
if err != nil {
return nil, fmt.Errorf("query clients: %w", err)
}
defer rows.Close()
var clients []*OAuthClient
for rows.Next() {
var redirectURIsJSON, grantTypesJSON, scopesJSON string
var oid sql.NullInt64
c := &OAuthClient{}
if err := rows.Scan(&c.ID, &c.SecretHash, &c.Name,
&redirectURIsJSON, &grantTypesJSON, &scopesJSON, &oid, &c.CreatedAt); err != nil {
return nil, fmt.Errorf("scan client: %w", err)
}
json.Unmarshal([]byte(redirectURIsJSON), &c.RedirectURIs)
json.Unmarshal([]byte(grantTypesJSON), &c.GrantTypes)
json.Unmarshal([]byte(scopesJSON), &c.Scopes)
if oid.Valid {
c.OwnerID = oid.Int64
}
if c.RedirectURIs == nil {
c.RedirectURIs = []string{}
}
if c.GrantTypes == nil {
c.GrantTypes = []string{}
}
if c.Scopes == nil {
c.Scopes = []string{}
}
clients = append(clients, c)
}
if clients == nil {
clients = []*OAuthClient{}
}
return clients, rows.Err()
}
// VerifyClientSecret checks a client_id/secret combination.
func (s *SQLiteClientStore) VerifyClientSecret(ctx context.Context, clientID, secret string) (*OAuthClient, error) {
client, err := s.GetClient(ctx, clientID)
if err != nil {
return nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(client.SecretHash), []byte(secret)); err != nil {
return nil, ErrInvalidPassword
}
return client, nil
}
// generateClientID creates a random client identifier.
func generateClientID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "synapbus_" + hex.EncodeToString(b), nil
}
// generateClientSecret creates a random client secret.
func generateClientSecret() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "sbs_" + hex.EncodeToString(b), nil
}
// HasGrantType checks if the client supports the given grant type.
func (c *OAuthClient) HasGrantType(grantType string) bool {
for _, gt := range c.GrantTypes {
if strings.EqualFold(gt, grantType) {
return true
}
}
return false
}