Files
breakpilot-core/consent-service/internal/handlers/oauth_handlers.go
Benjamin Admin 92c86ec6ba [split-required] [guardrail-change] Enforce 500 LOC budget across all services
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>
2026-04-27 00:09:30 +02:00

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)
}