Files
Jay Brown 58912a66d4 Merged in feature/support-empty-email-jwt (pull request #209)
handle missing email in jwt

* handle missing email
2026-02-13 01:38:32 +00:00

217 lines
7.1 KiB
Go

// eulaPublicHandlers.go implements the public EULA API handlers.
// These endpoints handle EULA retrieval and user agreement recording.
package queryapi
import (
"errors"
"fmt"
"net/http"
"time"
"queryorchestration/internal/cognitoauth"
"queryorchestration/internal/eula"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/oapi-codegen/nullable"
openapi_types "github.com/oapi-codegen/runtime/types"
)
// GetCurrentEula returns the current active EULA version.
// This endpoint is public and does not require authentication.
// GET /eula
//
// Returns:
// - 200 OK with EulaPublicResponse on success
// - 404 Not Found if no EULA is currently active
// - 500 Internal Server Error on database errors
func (s *Controllers) GetCurrentEula(ctx echo.Context) error {
version, err := s.svc.Eula.GetCurrentVersion(ctx.Request().Context())
if err != nil {
if errors.Is(err, eula.ErrNoCurrentVersion) {
return ctx.JSON(http.StatusNotFound, ErrorMessage{
Message: "No current EULA version configured",
})
}
s.cfg.GetAuthLogger().Error("GetCurrentEula: failed to get current EULA version",
"error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: fmt.Sprintf("Failed to get current EULA version: %v", err),
})
}
// Convert to public response
response := EulaPublicResponse{
Id: openapi_types.UUID(version.ID),
Version: version.Version,
Title: version.Title,
Content: version.Content,
}
// Handle nullable effectiveDate
if version.Effectivedate.Valid {
response.EffectiveDate = nullable.NewNullableWithValue(version.Effectivedate.Time)
}
return ctx.JSON(http.StatusOK, response)
}
// GetEulaStatus returns the current user's agreement status for the current EULA.
// Used by the frontend to determine if the user needs to be prompted to agree.
// GET /eula/status
//
// Requires authentication via JWT.
//
// Returns:
// - 200 OK with EulaStatusResponse on success
// - 401 Unauthorized if not authenticated
// - 404 Not Found if no EULA is currently active
// - 500 Internal Server Error on database errors
func (s *Controllers) GetEulaStatus(ctx echo.Context) error {
// Get user subject from JWT
userSubject, ok := cognitoauth.GetUserSubject(ctx)
if !ok {
return ctx.JSON(http.StatusUnauthorized, ErrorMessage{
Message: "Authentication required",
})
}
status, err := s.svc.Eula.GetUserEulaStatus(ctx.Request().Context(), userSubject)
if err != nil {
if errors.Is(err, eula.ErrNoCurrentVersion) {
return ctx.JSON(http.StatusNotFound, ErrorMessage{
Message: "No current EULA version configured",
})
}
s.cfg.GetAuthLogger().Error("GetEulaStatus: failed to get EULA status",
"error", err,
"user_subject", userSubject)
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: fmt.Sprintf("Failed to get EULA status: %v", err),
})
}
// Convert to API response
response := EulaStatusResponse{
HasAgreed: status.HasAgreed,
CurrentVersion: status.CurrentVersion,
CurrentVersionId: openapi_types.UUID(status.CurrentVersionID),
}
if status.AgreedAt != nil {
response.AgreedAt = nullable.NewNullableWithValue(*status.AgreedAt)
}
if status.AgreedVersion != nil {
response.AgreedVersion = nullable.NewNullableWithValue(*status.AgreedVersion)
}
return ctx.JSON(http.StatusOK, response)
}
// AgreeToEula records the current user's agreement to the current EULA version.
// This operation is idempotent - calling it multiple times will not create duplicate records.
// POST /eula/agree
//
// Requires authentication via JWT.
// IP address is automatically captured from the request.
//
// Returns:
// - 201 Created with EulaAgreementResponse for new agreements
// - 200 OK with EulaAgreementResponse if user already agreed (idempotent)
// - 400 Bad Request if no current EULA is configured
// - 401 Unauthorized if not authenticated
// - 500 Internal Server Error on database errors
func (s *Controllers) AgreeToEula(ctx echo.Context) error {
// Get user info from JWT
userSubject, subjectOK := cognitoauth.GetUserSubject(ctx)
userInfo, infoOK := cognitoauth.GetUserInfo(ctx)
if !subjectOK || !infoOK {
s.cfg.GetAuthLogger().Error("AgreeToEula: authentication info missing from context",
"subject_ok", subjectOK,
"info_ok", infoOK)
return ctx.JSON(http.StatusUnauthorized, ErrorMessage{
Message: "Authentication required",
})
}
// Resolve the user identifier for the agreement record.
// Cognito access tokens do not include the email claim by default (only ID tokens do).
// Fall back to username, then to subject ID (always present in any Cognito token).
userIdentifier := userInfo.Email
if userIdentifier == "" {
userIdentifier = userInfo.Username
s.cfg.GetAuthLogger().Info("AgreeToEula: email missing from token, using username fallback",
"user_subject", userSubject,
"username", userInfo.Username)
}
if userIdentifier == "" {
userIdentifier = userSubject
s.cfg.GetAuthLogger().Info("AgreeToEula: email and username missing from token, using subject ID fallback",
"user_subject", userSubject)
}
// Get current EULA version
currentVersion, err := s.svc.Eula.GetCurrentVersion(ctx.Request().Context())
if err != nil {
if errors.Is(err, eula.ErrNoCurrentVersion) {
return ctx.JSON(http.StatusBadRequest, ErrorMessage{
Message: "No current EULA version configured",
})
}
s.cfg.GetAuthLogger().Error("AgreeToEula: failed to get current EULA version",
"error", err,
"user_subject", userSubject)
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: fmt.Sprintf("Failed to get current EULA version: %v", err),
})
}
// Get client IP address
ipAddress := ctx.RealIP()
if ipAddress == "" {
ipAddress = ctx.Request().RemoteAddr
}
// Record the agreement
result, err := s.svc.Eula.RecordAgreement(ctx.Request().Context(), &eula.RecordAgreementInput{
CognitoSubjectID: userSubject,
UserEmail: userIdentifier,
EulaVersionID: currentVersion.ID,
IPAddress: ipAddress,
})
if err != nil {
s.cfg.GetAuthLogger().Error("AgreeToEula: failed to record EULA agreement",
"error", err,
"user_subject", userSubject,
"user_identifier", userIdentifier,
"eula_version_id", currentVersion.ID)
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: fmt.Sprintf("Failed to record EULA agreement: %v", err),
})
}
// Build response
var agreedAt time.Time
if result.Agreement.Agreedat.Valid {
agreedAt = result.Agreement.Agreedat.Time
}
response := EulaAgreementResponse{
Id: openapi_types.UUID(result.Agreement.ID),
EulaVersionId: openapi_types.UUID(result.Agreement.Eulaversionid),
EulaVersion: currentVersion.Version,
AgreedAt: agreedAt,
}
// Return 200 if already agreed (idempotent), 201 for new agreement
if result.AlreadyAgreed {
return ctx.JSON(http.StatusOK, response)
}
return ctx.JSON(http.StatusCreated, response)
}
// convertVersionIDParam converts the path parameter to uuid.UUID.
// Returns error if the ID format is invalid.
func convertVersionIDParam(versionID EulaVersionID) (uuid.UUID, error) {
return uuid.UUID(versionID), nil
}