Files
synapbus/internal/auth/client_store.go
T
Algis DumbrisandClaude Opus 4.6 8a1c096355 feat: implement human auth with OAuth 2.1 (fosite)
Add complete auth subsystem with OAuth 2.1 authorization server using
ory/fosite, local user accounts with bcrypt password hashing, session
management, and HTTP handlers for the Web UI.

Components:
- User store with bcrypt hashing (configurable cost, default 12), CRUD,
  validation (username 3-64 chars alphanumeric+underscore, password 8-72 bytes)
- Session store with secure random IDs, configurable lifetime (default 24h),
  expiration cleanup, and per-user invalidation
- OAuth client store with client_id/secret generation and bcrypt verification
- Fosite storage adapter implementing CoreStorage, TokenRevocationStorage,
  and PKCERequestStorage backed by SQLite
- OAuth provider configured with authorization code (PKCE S256 mandatory),
  client credentials, refresh token rotation, and token introspection
- HTTP handlers: POST /auth/register, POST /auth/login, POST /auth/logout,
  GET /auth/me, PUT /auth/password, GET /oauth/authorize, POST /oauth/token,
  POST /oauth/introspect
- Middleware: RequireSession (cookie), RequireBearer (access token),
  RequireAuth (either), RequireAdmin (role check)
- Structured auth event logging (login, token issuance, session lifecycle)
- Schema migration 002_auth.sql extending users, oauth_clients, oauth_tokens
  tables and adding sessions, oauth_authorization_codes tables
- Initial admin user auto-created on first run with random password printed
  to stdout
- All tests pass with CGO_ENABLED=0, zero external runtime dependencies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:59:37 +02:00

215 lines
6.1 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)
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
}
// 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
}