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>
383 lines
11 KiB
Go
383 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/breakpilot/consent-service/internal/middleware"
|
|
"github.com/breakpilot/consent-service/internal/services"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// OAuthHandler handles OAuth 2.0 endpoints
|
|
type OAuthHandler struct {
|
|
oauthService *services.OAuthService
|
|
totpService *services.TOTPService
|
|
authService *services.AuthService
|
|
}
|
|
|
|
// NewOAuthHandler creates a new OAuthHandler
|
|
func NewOAuthHandler(oauthService *services.OAuthService, totpService *services.TOTPService, authService *services.AuthService) *OAuthHandler {
|
|
return &OAuthHandler{
|
|
oauthService: oauthService,
|
|
totpService: totpService,
|
|
authService: authService,
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// OAuth 2.0 Authorization Code Flow
|
|
// ========================================
|
|
|
|
// Authorize handles the OAuth 2.0 authorization request
|
|
// GET /oauth/authorize
|
|
func (h *OAuthHandler) Authorize(c *gin.Context) {
|
|
responseType := c.Query("response_type")
|
|
clientID := c.Query("client_id")
|
|
redirectURI := c.Query("redirect_uri")
|
|
scope := c.Query("scope")
|
|
state := c.Query("state")
|
|
codeChallenge := c.Query("code_challenge")
|
|
codeChallengeMethod := c.Query("code_challenge_method")
|
|
|
|
// Validate response_type
|
|
if responseType != "code" {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "unsupported_response_type",
|
|
"error_description": "Only 'code' response_type is supported",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Validate client
|
|
ctx := context.Background()
|
|
client, err := h.oauthService.ValidateClient(ctx, clientID)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "invalid_client",
|
|
"error_description": "Unknown or invalid client_id",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Validate redirect_uri
|
|
if err := h.oauthService.ValidateRedirectURI(client, redirectURI); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "invalid_request",
|
|
"error_description": "Invalid redirect_uri",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Validate scopes
|
|
scopes, err := h.oauthService.ValidateScopes(client, scope)
|
|
if err != nil {
|
|
redirectWithError(c, redirectURI, "invalid_scope", "One or more requested scopes are invalid", state)
|
|
return
|
|
}
|
|
|
|
// For public clients, PKCE is required
|
|
if client.IsPublic && codeChallenge == "" {
|
|
redirectWithError(c, redirectURI, "invalid_request", "PKCE code_challenge is required for public clients", state)
|
|
return
|
|
}
|
|
|
|
// Get authenticated user
|
|
userID, err := middleware.GetUserID(c)
|
|
if err != nil || userID == uuid.Nil {
|
|
// User not authenticated - redirect to login
|
|
// Store authorization request in session and redirect to login
|
|
c.JSON(http.StatusUnauthorized, gin.H{
|
|
"error": "login_required",
|
|
"error_description": "User must be authenticated to authorize",
|
|
"login_url": "/auth/login",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Generate authorization code
|
|
code, err := h.oauthService.GenerateAuthorizationCode(
|
|
ctx, client, userID, redirectURI, scopes, codeChallenge, codeChallengeMethod,
|
|
)
|
|
if err != nil {
|
|
redirectWithError(c, redirectURI, "server_error", "Failed to generate authorization code", state)
|
|
return
|
|
}
|
|
|
|
// Redirect with code
|
|
redirectURL := redirectURI + "?code=" + code
|
|
if state != "" {
|
|
redirectURL += "&state=" + state
|
|
}
|
|
|
|
c.Redirect(http.StatusFound, redirectURL)
|
|
}
|
|
|
|
// Token handles the OAuth 2.0 token request
|
|
// POST /oauth/token
|
|
func (h *OAuthHandler) Token(c *gin.Context) {
|
|
grantType := c.PostForm("grant_type")
|
|
|
|
switch grantType {
|
|
case "authorization_code":
|
|
h.tokenAuthorizationCode(c)
|
|
case "refresh_token":
|
|
h.tokenRefreshToken(c)
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "unsupported_grant_type",
|
|
"error_description": "Only 'authorization_code' and 'refresh_token' grant types are supported",
|
|
})
|
|
}
|
|
}
|
|
|
|
// tokenAuthorizationCode handles the authorization_code grant
|
|
func (h *OAuthHandler) tokenAuthorizationCode(c *gin.Context) {
|
|
code := c.PostForm("code")
|
|
clientID := c.PostForm("client_id")
|
|
redirectURI := c.PostForm("redirect_uri")
|
|
codeVerifier := c.PostForm("code_verifier")
|
|
|
|
if code == "" || clientID == "" || redirectURI == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "invalid_request",
|
|
"error_description": "Missing required parameters: code, client_id, redirect_uri",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Validate client
|
|
ctx := context.Background()
|
|
client, err := h.oauthService.ValidateClient(ctx, clientID)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "invalid_client",
|
|
"error_description": "Unknown or invalid client_id",
|
|
})
|
|
return
|
|
}
|
|
|
|
// For confidential clients, validate client_secret
|
|
if !client.IsPublic {
|
|
clientSecret := c.PostForm("client_secret")
|
|
if err := h.oauthService.ValidateClientSecret(client, clientSecret); err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{
|
|
"error": "invalid_client",
|
|
"error_description": "Invalid client credentials",
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Exchange authorization code for tokens
|
|
tokenResponse, err := h.oauthService.ExchangeAuthorizationCode(ctx, code, clientID, redirectURI, codeVerifier)
|
|
if err != nil {
|
|
switch err {
|
|
case services.ErrCodeExpired:
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "invalid_grant",
|
|
"error_description": "Authorization code has expired",
|
|
})
|
|
case services.ErrCodeUsed:
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "invalid_grant",
|
|
"error_description": "Authorization code has already been used",
|
|
})
|
|
case services.ErrPKCEVerifyFailed:
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "invalid_grant",
|
|
"error_description": "PKCE verification failed",
|
|
})
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "invalid_grant",
|
|
"error_description": "Invalid authorization code",
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, tokenResponse)
|
|
}
|
|
|
|
// tokenRefreshToken handles the refresh_token grant
|
|
func (h *OAuthHandler) tokenRefreshToken(c *gin.Context) {
|
|
refreshToken := c.PostForm("refresh_token")
|
|
clientID := c.PostForm("client_id")
|
|
scope := c.PostForm("scope")
|
|
|
|
if refreshToken == "" || clientID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "invalid_request",
|
|
"error_description": "Missing required parameters: refresh_token, client_id",
|
|
})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Refresh access token
|
|
tokenResponse, err := h.oauthService.RefreshAccessToken(ctx, refreshToken, clientID, scope)
|
|
if err != nil {
|
|
switch err {
|
|
case services.ErrInvalidScope:
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "invalid_scope",
|
|
"error_description": "Requested scope exceeds original grant",
|
|
})
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "invalid_grant",
|
|
"error_description": "Invalid or expired refresh token",
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, tokenResponse)
|
|
}
|
|
|
|
// Revoke handles token revocation
|
|
// POST /oauth/revoke
|
|
func (h *OAuthHandler) Revoke(c *gin.Context) {
|
|
token := c.PostForm("token")
|
|
tokenTypeHint := c.PostForm("token_type_hint")
|
|
|
|
if token == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "invalid_request",
|
|
"error_description": "Missing token parameter",
|
|
})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
_ = h.oauthService.RevokeToken(ctx, token, tokenTypeHint)
|
|
|
|
// RFC 7009: Always return 200 OK
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
// Introspect handles token introspection (for resource servers)
|
|
// POST /oauth/introspect
|
|
func (h *OAuthHandler) Introspect(c *gin.Context) {
|
|
token := c.PostForm("token")
|
|
|
|
if token == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "invalid_request",
|
|
"error_description": "Missing token parameter",
|
|
})
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
claims, err := h.oauthService.ValidateAccessToken(ctx, token)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, gin.H{"active": false})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"active": true,
|
|
"sub": (*claims)["sub"],
|
|
"client_id": (*claims)["client_id"],
|
|
"scope": (*claims)["scope"],
|
|
"exp": (*claims)["exp"],
|
|
"iat": (*claims)["iat"],
|
|
"iss": (*claims)["iss"],
|
|
})
|
|
}
|
|
|
|
// ========================================
|
|
// OAuth Client Management (Admin)
|
|
// ========================================
|
|
|
|
// AdminCreateClient creates a new OAuth client
|
|
// POST /admin/oauth/clients
|
|
func (h *OAuthHandler) AdminCreateClient(c *gin.Context) {
|
|
var req struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Description string `json:"description"`
|
|
RedirectURIs []string `json:"redirect_uris" binding:"required"`
|
|
Scopes []string `json:"scopes"`
|
|
GrantTypes []string `json:"grant_types"`
|
|
IsPublic bool `json:"is_public"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
userID, _ := middleware.GetUserID(c)
|
|
|
|
// Default scopes
|
|
if len(req.Scopes) == 0 {
|
|
req.Scopes = []string{"openid", "profile", "email"}
|
|
}
|
|
|
|
// Default grant types
|
|
if len(req.GrantTypes) == 0 {
|
|
req.GrantTypes = []string{"authorization_code", "refresh_token"}
|
|
}
|
|
|
|
ctx := context.Background()
|
|
client, clientSecret, err := h.oauthService.CreateClient(
|
|
ctx, req.Name, req.Description, req.RedirectURIs, req.Scopes, req.GrantTypes, req.IsPublic, &userID,
|
|
)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create client"})
|
|
return
|
|
}
|
|
|
|
response := gin.H{
|
|
"client_id": client.ClientID,
|
|
"name": client.Name,
|
|
"redirect_uris": client.RedirectURIs,
|
|
"scopes": client.Scopes,
|
|
"grant_types": client.GrantTypes,
|
|
"is_public": client.IsPublic,
|
|
}
|
|
|
|
// Only show client_secret once for confidential clients
|
|
if !client.IsPublic && clientSecret != "" {
|
|
response["client_secret"] = clientSecret
|
|
response["client_secret_warning"] = "Store this secret securely. It will not be shown again."
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, response)
|
|
}
|
|
|
|
// AdminGetClients lists all OAuth clients
|
|
// GET /admin/oauth/clients
|
|
func (h *OAuthHandler) AdminGetClients(c *gin.Context) {
|
|
// This would need a new method in OAuthService
|
|
// For now, return a placeholder
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"clients": []interface{}{},
|
|
"message": "Client listing not yet implemented",
|
|
})
|
|
}
|
|
|
|
// ========================================
|
|
// Helper Functions
|
|
// ========================================
|
|
|
|
func redirectWithError(c *gin.Context, redirectURI, errorCode, errorDescription, state string) {
|
|
separator := "?"
|
|
if strings.Contains(redirectURI, "?") {
|
|
separator = "&"
|
|
}
|
|
|
|
redirectURL := redirectURI + separator + "error=" + errorCode + "&error_description=" + errorDescription
|
|
if state != "" {
|
|
redirectURL += "&state=" + state
|
|
}
|
|
|
|
c.Redirect(http.StatusFound, redirectURL)
|
|
}
|