Files
Jay Brown 963ccc6553 Merged in feature/demo-support (pull request #207)
Feature/demo support

* index logs now debug

* demo data loader
2026-02-02 19:08:08 +00:00

313 lines
9.6 KiB
Go

// errors.go provides error classification for AWS Cognito errors.
// It maps AWS error types to appropriate HTTP status codes and sanitized error messages
// suitable for returning in API responses without leaking sensitive information.
package usermanagement
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider/types"
"github.com/aws/smithy-go"
)
// CognitoErrorType represents a machine-readable error classification.
type CognitoErrorType string
const (
// ErrTypeUserNotFound indicates the user does not exist in Cognito.
ErrTypeUserNotFound CognitoErrorType = "UserNotFound"
// ErrTypeAccessDenied indicates the caller does not have permission to perform the operation.
ErrTypeAccessDenied CognitoErrorType = "AccessDenied"
// ErrTypeNotAuthorized indicates the caller is not authorized (e.g., invalid credentials).
ErrTypeNotAuthorized CognitoErrorType = "NotAuthorized"
// ErrTypeInvalidParameter indicates an invalid parameter was provided.
ErrTypeInvalidParameter CognitoErrorType = "InvalidParameter"
// ErrTypeRateLimited indicates too many requests were made.
ErrTypeRateLimited CognitoErrorType = "RateLimited"
// ErrTypeServiceUnavailable indicates the Cognito service is temporarily unavailable.
ErrTypeServiceUnavailable CognitoErrorType = "ServiceUnavailable"
// ErrTypeServiceError indicates a general error communicating with Cognito.
ErrTypeServiceError CognitoErrorType = "ServiceError"
// ErrTypeUserExists indicates the user already exists in Cognito.
ErrTypeUserExists CognitoErrorType = "UserExists"
)
// CognitoErrorInfo contains classified error information suitable for HTTP responses.
// The Message and Details fields are sanitized to avoid leaking sensitive information
// like ARNs, internal paths, or request IDs.
type CognitoErrorInfo struct {
// HTTPStatus is the appropriate HTTP status code for this error type.
HTTPStatus int
// ErrorType is a machine-readable error classification.
ErrorType CognitoErrorType
// Message is a user-friendly error message safe for HTTP responses.
Message string
// Details provides additional error context (sanitized).
Details string
}
// ClassifyCognitoError analyzes a Cognito error and returns appropriate HTTP response info.
// The returned CognitoErrorInfo contains sanitized messages suitable for API responses.
//
// Parameters:
// - err: The error returned from a Cognito operation
// - email: The user's email address for context in error messages
//
// Returns:
// - CognitoErrorInfo: Classified error information with appropriate HTTP status and messages
func ClassifyCognitoError(err error, email string) CognitoErrorInfo {
if err == nil {
return CognitoErrorInfo{
HTTPStatus: http.StatusOK,
ErrorType: "",
Message: "",
Details: "",
}
}
// Check for specific Cognito SDK error types
var userNotFoundErr *types.UserNotFoundException
if errors.As(err, &userNotFoundErr) {
return CognitoErrorInfo{
HTTPStatus: http.StatusNotFound,
ErrorType: ErrTypeUserNotFound,
Message: fmt.Sprintf("User not found: %s", email),
Details: "The specified user does not exist in Cognito",
}
}
var notAuthorizedErr *types.NotAuthorizedException
if errors.As(err, &notAuthorizedErr) {
return CognitoErrorInfo{
HTTPStatus: http.StatusUnauthorized,
ErrorType: ErrTypeNotAuthorized,
Message: "Not authorized to perform this operation",
Details: sanitizeErrorMessage(err.Error()),
}
}
var invalidParamErr *types.InvalidParameterException
if errors.As(err, &invalidParamErr) {
return CognitoErrorInfo{
HTTPStatus: http.StatusBadRequest,
ErrorType: ErrTypeInvalidParameter,
Message: "Invalid parameter in request",
Details: sanitizeErrorMessage(err.Error()),
}
}
var tooManyRequestsErr *types.TooManyRequestsException
if errors.As(err, &tooManyRequestsErr) {
return CognitoErrorInfo{
HTTPStatus: http.StatusTooManyRequests,
ErrorType: ErrTypeRateLimited,
Message: "Too many requests to Cognito",
Details: "Rate limit exceeded. Please retry after a short delay.",
}
}
var internalErr *types.InternalErrorException
if errors.As(err, &internalErr) {
return CognitoErrorInfo{
HTTPStatus: http.StatusServiceUnavailable,
ErrorType: ErrTypeServiceUnavailable,
Message: "Cognito service temporarily unavailable",
Details: "Internal service error. Please retry later.",
}
}
var usernameExistsErr *types.UsernameExistsException
if errors.As(err, &usernameExistsErr) {
return CognitoErrorInfo{
HTTPStatus: http.StatusConflict,
ErrorType: ErrTypeUserExists,
Message: fmt.Sprintf("User already exists: %s", email),
Details: "A user with this email already exists in Cognito",
}
}
// Check for generic AWS API errors (including AccessDeniedException)
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
return classifyAPIError(apiErr, email)
}
// Default: treat as a generic service communication error
return CognitoErrorInfo{
HTTPStatus: http.StatusBadGateway,
ErrorType: ErrTypeServiceError,
Message: "Failed to communicate with Cognito",
Details: sanitizeErrorMessage(err.Error()),
}
}
// classifyAPIError handles generic AWS API errors that may not have specific SDK types.
//
// Parameters:
// - apiErr: The AWS API error
// - email: The user's email address for context in error messages
//
// Returns:
// - CognitoErrorInfo: Classified error information
func classifyAPIError(apiErr smithy.APIError, email string) CognitoErrorInfo {
code := apiErr.ErrorCode()
switch code {
case "AccessDeniedException":
return CognitoErrorInfo{
HTTPStatus: http.StatusForbidden,
ErrorType: ErrTypeAccessDenied,
Message: "Access denied to Cognito user pool",
Details: "The service does not have permission to perform this Cognito operation",
}
case "UserNotFoundException":
return CognitoErrorInfo{
HTTPStatus: http.StatusNotFound,
ErrorType: ErrTypeUserNotFound,
Message: fmt.Sprintf("User not found: %s", email),
Details: "The specified user does not exist in Cognito",
}
case "NotAuthorizedException":
return CognitoErrorInfo{
HTTPStatus: http.StatusUnauthorized,
ErrorType: ErrTypeNotAuthorized,
Message: "Not authorized to perform this operation",
Details: sanitizeErrorMessage(apiErr.ErrorMessage()),
}
case "InvalidParameterException":
return CognitoErrorInfo{
HTTPStatus: http.StatusBadRequest,
ErrorType: ErrTypeInvalidParameter,
Message: "Invalid parameter in request",
Details: sanitizeErrorMessage(apiErr.ErrorMessage()),
}
case "TooManyRequestsException":
return CognitoErrorInfo{
HTTPStatus: http.StatusTooManyRequests,
ErrorType: ErrTypeRateLimited,
Message: "Too many requests to Cognito",
Details: "Rate limit exceeded. Please retry after a short delay.",
}
case "ServiceUnavailable", "InternalError", "InternalErrorException":
return CognitoErrorInfo{
HTTPStatus: http.StatusServiceUnavailable,
ErrorType: ErrTypeServiceUnavailable,
Message: "Cognito service temporarily unavailable",
Details: "Internal service error. Please retry later.",
}
case "UsernameExistsException":
return CognitoErrorInfo{
HTTPStatus: http.StatusConflict,
ErrorType: ErrTypeUserExists,
Message: fmt.Sprintf("User already exists: %s", email),
Details: "A user with this email already exists in Cognito",
}
default:
return CognitoErrorInfo{
HTTPStatus: http.StatusBadGateway,
ErrorType: ErrTypeServiceError,
Message: "Failed to communicate with Cognito",
Details: sanitizeErrorMessage(apiErr.ErrorMessage()),
}
}
}
// sanitizeErrorMessage removes sensitive information from error messages.
// It strips out ARNs, request IDs, and internal resource names.
//
// Parameters:
// - msg: The raw error message
//
// Returns:
// - string: The sanitized error message
func sanitizeErrorMessage(msg string) string {
msg = removeARNs(msg)
msg = removeRequestIDs(msg)
msg = normalizeWhitespace(msg)
return msg
}
// removeARNs replaces all AWS ARNs with [REDACTED].
func removeARNs(msg string) string {
for {
start := strings.Index(msg, "arn:aws:")
if start == -1 {
return msg
}
end := findEndOfToken(msg, start)
msg = msg[:start] + "[REDACTED]" + msg[end:]
}
}
// removeRequestIDs removes request ID patterns and their values from the message.
func removeRequestIDs(msg string) string {
patterns := []string{"requestid:", "request-id:", "request id:"}
msgLower := strings.ToLower(msg)
for _, pattern := range patterns {
for {
idx := strings.Index(msgLower, pattern)
if idx == -1 {
break
}
end := idx + len(pattern)
end = skipWhitespace(msg, end)
end = findEndOfToken(msg, end)
msg = msg[:idx] + msg[end:]
msgLower = strings.ToLower(msg)
}
}
return msg
}
// findEndOfToken finds the end of a token (space, comma, or newline delimited).
func findEndOfToken(msg string, start int) int {
end := start
for end < len(msg) && !isTokenDelimiter(msg[end]) {
end++
}
return end
}
// isTokenDelimiter returns true if the character is a token delimiter.
func isTokenDelimiter(c byte) bool {
return c == ' ' || c == ',' || c == '\n' || c == '\r'
}
// skipWhitespace advances past whitespace characters.
func skipWhitespace(msg string, pos int) int {
for pos < len(msg) && (msg[pos] == ' ' || msg[pos] == '\t') {
pos++
}
return pos
}
// normalizeWhitespace trims and collapses multiple spaces.
func normalizeWhitespace(msg string) string {
msg = strings.TrimSpace(msg)
for strings.Contains(msg, " ") {
msg = strings.ReplaceAll(msg, " ", " ")
}
return msg
}