Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook) and split all 44 files exceeding 500 LOC into domain-focused modules: - consent-service (Go): models, handlers, services, database splits - backend-core (Python): security_api, rbac_api, pdf_service, auth splits - admin-core (TypeScript): 5 page.tsx + sidebar extractions - pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits - voice-service (Python): enhanced_task_orchestrator split Result: 0 violations, 36 exempted (pipeline, tests, pure-data files). Go build verified clean. No behavior changes — pure structural splits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
246 lines
7.3 KiB
Go
246 lines
7.3 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/breakpilot/consent-service/internal/models"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidClient = errors.New("invalid_client")
|
|
ErrInvalidGrant = errors.New("invalid_grant")
|
|
ErrInvalidScope = errors.New("invalid_scope")
|
|
ErrInvalidRequest = errors.New("invalid_request")
|
|
ErrUnauthorizedClient = errors.New("unauthorized_client")
|
|
ErrAccessDenied = errors.New("access_denied")
|
|
ErrInvalidRedirectURI = errors.New("invalid redirect_uri")
|
|
ErrCodeExpired = errors.New("authorization code expired")
|
|
ErrCodeUsed = errors.New("authorization code already used")
|
|
ErrPKCERequired = errors.New("PKCE code_challenge required for public clients")
|
|
ErrPKCEVerifyFailed = errors.New("PKCE verification failed")
|
|
)
|
|
|
|
// OAuthService handles OAuth 2.0 Authorization Code Flow with PKCE
|
|
type OAuthService struct {
|
|
db *pgxpool.Pool
|
|
jwtSecret string
|
|
authCodeExpiration time.Duration
|
|
accessTokenExpiration time.Duration
|
|
refreshTokenExpiration time.Duration
|
|
}
|
|
|
|
// NewOAuthService creates a new OAuthService
|
|
func NewOAuthService(db *pgxpool.Pool, jwtSecret string) *OAuthService {
|
|
return &OAuthService{
|
|
db: db,
|
|
jwtSecret: jwtSecret,
|
|
authCodeExpiration: 10 * time.Minute, // Authorization codes expire quickly
|
|
accessTokenExpiration: time.Hour, // 1 hour
|
|
refreshTokenExpiration: 30 * 24 * time.Hour, // 30 days
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// Client Validation
|
|
// ========================================
|
|
|
|
// ValidateClient validates an OAuth client
|
|
func (s *OAuthService) ValidateClient(ctx context.Context, clientID string) (*models.OAuthClient, error) {
|
|
var client models.OAuthClient
|
|
var redirectURIsJSON, scopesJSON, grantTypesJSON []byte
|
|
|
|
err := s.db.QueryRow(ctx, `
|
|
SELECT id, client_id, client_secret, name, description, redirect_uris, scopes, grant_types, is_public, is_active, created_at
|
|
FROM oauth_clients WHERE client_id = $1
|
|
`, clientID).Scan(
|
|
&client.ID, &client.ClientID, &client.ClientSecret, &client.Name, &client.Description,
|
|
&redirectURIsJSON, &scopesJSON, &grantTypesJSON, &client.IsPublic, &client.IsActive, &client.CreatedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, ErrInvalidClient
|
|
}
|
|
|
|
if !client.IsActive {
|
|
return nil, ErrInvalidClient
|
|
}
|
|
|
|
// Parse JSON arrays
|
|
json.Unmarshal(redirectURIsJSON, &client.RedirectURIs)
|
|
json.Unmarshal(scopesJSON, &client.Scopes)
|
|
json.Unmarshal(grantTypesJSON, &client.GrantTypes)
|
|
|
|
return &client, nil
|
|
}
|
|
|
|
// ValidateClientSecret validates client credentials for confidential clients
|
|
func (s *OAuthService) ValidateClientSecret(client *models.OAuthClient, clientSecret string) error {
|
|
if client.IsPublic {
|
|
// Public clients don't have a secret
|
|
return nil
|
|
}
|
|
|
|
if client.ClientSecret != clientSecret {
|
|
return ErrInvalidClient
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateRedirectURI validates the redirect URI against registered URIs
|
|
func (s *OAuthService) ValidateRedirectURI(client *models.OAuthClient, redirectURI string) error {
|
|
for _, uri := range client.RedirectURIs {
|
|
if uri == redirectURI {
|
|
return nil
|
|
}
|
|
}
|
|
return ErrInvalidRedirectURI
|
|
}
|
|
|
|
// ValidateScopes validates requested scopes against client's allowed scopes
|
|
func (s *OAuthService) ValidateScopes(client *models.OAuthClient, requestedScopes string) ([]string, error) {
|
|
if requestedScopes == "" {
|
|
// Return default scopes
|
|
return []string{"openid", "profile", "email"}, nil
|
|
}
|
|
|
|
requested := strings.Split(requestedScopes, " ")
|
|
allowedMap := make(map[string]bool)
|
|
for _, scope := range client.Scopes {
|
|
allowedMap[scope] = true
|
|
}
|
|
|
|
var validScopes []string
|
|
for _, scope := range requested {
|
|
if allowedMap[scope] {
|
|
validScopes = append(validScopes, scope)
|
|
}
|
|
}
|
|
|
|
if len(validScopes) == 0 {
|
|
return nil, ErrInvalidScope
|
|
}
|
|
|
|
return validScopes, nil
|
|
}
|
|
|
|
// ========================================
|
|
// Authorization Code
|
|
// ========================================
|
|
|
|
// GenerateAuthorizationCode generates a new authorization code
|
|
func (s *OAuthService) GenerateAuthorizationCode(
|
|
ctx context.Context,
|
|
client *models.OAuthClient,
|
|
userID uuid.UUID,
|
|
redirectURI string,
|
|
scopes []string,
|
|
codeChallenge, codeChallengeMethod string,
|
|
) (string, error) {
|
|
// For public clients, PKCE is required
|
|
if client.IsPublic && codeChallenge == "" {
|
|
return "", ErrPKCERequired
|
|
}
|
|
|
|
// Generate a secure random code
|
|
codeBytes := make([]byte, 32)
|
|
if _, err := rand.Read(codeBytes); err != nil {
|
|
return "", fmt.Errorf("failed to generate code: %w", err)
|
|
}
|
|
code := base64.URLEncoding.EncodeToString(codeBytes)
|
|
|
|
// Hash the code for storage
|
|
codeHash := sha256.Sum256([]byte(code))
|
|
hashedCode := hex.EncodeToString(codeHash[:])
|
|
|
|
scopesJSON, _ := json.Marshal(scopes)
|
|
|
|
var challengePtr, methodPtr *string
|
|
if codeChallenge != "" {
|
|
challengePtr = &codeChallenge
|
|
if codeChallengeMethod == "" {
|
|
codeChallengeMethod = "plain"
|
|
}
|
|
methodPtr = &codeChallengeMethod
|
|
}
|
|
|
|
_, err := s.db.Exec(ctx, `
|
|
INSERT INTO oauth_authorization_codes (code, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, expires_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
`, hashedCode, client.ClientID, userID, redirectURI, scopesJSON, challengePtr, methodPtr, time.Now().Add(s.authCodeExpiration))
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to store authorization code: %w", err)
|
|
}
|
|
|
|
return code, nil
|
|
}
|
|
|
|
// ========================================
|
|
// Client Management (Admin)
|
|
// ========================================
|
|
|
|
// GetClientByID retrieves an OAuth client by its client_id
|
|
func (s *OAuthService) GetClientByID(ctx context.Context, clientID string) (*models.OAuthClient, error) {
|
|
return s.ValidateClient(ctx, clientID)
|
|
}
|
|
|
|
// CreateClient creates a new OAuth client (admin only)
|
|
func (s *OAuthService) CreateClient(
|
|
ctx context.Context,
|
|
name, description string,
|
|
redirectURIs, scopes, grantTypes []string,
|
|
isPublic bool,
|
|
createdBy *uuid.UUID,
|
|
) (*models.OAuthClient, string, error) {
|
|
// Generate client_id
|
|
clientIDBytes := make([]byte, 16)
|
|
rand.Read(clientIDBytes)
|
|
clientID := hex.EncodeToString(clientIDBytes)
|
|
|
|
// Generate client_secret for confidential clients
|
|
var clientSecret string
|
|
var clientSecretPtr *string
|
|
if !isPublic {
|
|
secretBytes := make([]byte, 32)
|
|
rand.Read(secretBytes)
|
|
clientSecret = base64.URLEncoding.EncodeToString(secretBytes)
|
|
clientSecretPtr = &clientSecret
|
|
}
|
|
|
|
redirectURIsJSON, _ := json.Marshal(redirectURIs)
|
|
scopesJSON, _ := json.Marshal(scopes)
|
|
grantTypesJSON, _ := json.Marshal(grantTypes)
|
|
|
|
var client models.OAuthClient
|
|
err := s.db.QueryRow(ctx, `
|
|
INSERT INTO oauth_clients (client_id, client_secret, name, description, redirect_uris, scopes, grant_types, is_public, created_by)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id, client_id, name, is_public, is_active, created_at
|
|
`, clientID, clientSecretPtr, name, description, redirectURIsJSON, scopesJSON, grantTypesJSON, isPublic, createdBy).Scan(
|
|
&client.ID, &client.ClientID, &client.Name, &client.IsPublic, &client.IsActive, &client.CreatedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to create client: %w", err)
|
|
}
|
|
|
|
client.RedirectURIs = redirectURIs
|
|
client.Scopes = scopes
|
|
client.GrantTypes = grantTypes
|
|
|
|
return &client, clientSecret, nil
|
|
}
|