package internalhttp import ( "context" "errors" "fmt" "net/http" "strings" "sync" "time" "galaxy/authsession/internal/service/blockuser" "galaxy/authsession/internal/service/getsession" "galaxy/authsession/internal/service/listusersessions" "galaxy/authsession/internal/service/revokeallusersessions" "galaxy/authsession/internal/service/revokedevicesession" "galaxy/authsession/internal/service/shared" "galaxy/authsession/internal/telemetry" "github.com/gin-gonic/gin" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" ) const jsonContentType = "application/json; charset=utf-8" const internalHTTPServiceName = "galaxy-authsession-internal" type errorResponse struct { Error errorBody `json:"error"` } type errorBody struct { Code string `json:"code"` Message string `json:"message"` } type actorRequest struct { Type string `json:"type"` ID string `json:"id,omitempty"` } type sessionResponseDTO struct { DeviceSessionID string `json:"device_session_id"` UserID string `json:"user_id"` ClientPublicKey string `json:"client_public_key"` Status string `json:"status"` CreatedAt string `json:"created_at"` RevokedAt *string `json:"revoked_at,omitempty"` RevokeReasonCode *string `json:"revoke_reason_code,omitempty"` RevokeActorType *string `json:"revoke_actor_type,omitempty"` RevokeActorID *string `json:"revoke_actor_id,omitempty"` } type getSessionResponse struct { Session sessionResponseDTO `json:"session"` } type listUserSessionsResponse struct { Sessions []sessionResponseDTO `json:"sessions"` } type revokeDeviceSessionRequest struct { ReasonCode string `json:"reason_code"` Actor actorRequest `json:"actor"` } type revokeDeviceSessionResponse struct { Outcome string `json:"outcome"` DeviceSessionID string `json:"device_session_id"` AffectedSessionCount int64 `json:"affected_session_count"` } type revokeAllUserSessionsRequest struct { ReasonCode string `json:"reason_code"` Actor actorRequest `json:"actor"` } type revokeAllUserSessionsResponse struct { Outcome string `json:"outcome"` UserID string `json:"user_id"` AffectedSessionCount int64 `json:"affected_session_count"` AffectedDeviceSessionIDs []string `json:"affected_device_session_ids"` } type blockUserRequest struct { UserID string `json:"user_id,omitempty"` Email string `json:"email,omitempty"` ReasonCode string `json:"reason_code"` Actor actorRequest `json:"actor"` } type blockUserResponse struct { Outcome string `json:"outcome"` SubjectKind string `json:"subject_kind"` SubjectValue string `json:"subject_value"` AffectedSessionCount int64 `json:"affected_session_count"` AffectedDeviceSessionIDs []string `json:"affected_device_session_ids"` } var configureGinModeOnce sync.Once func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) { if err := cfg.Validate(); err != nil { return nil, err } normalizedDeps, err := normalizeDependencies(deps) if err != nil { return nil, err } configureGinModeOnce.Do(func() { gin.SetMode(gin.ReleaseMode) }) engine := gin.New() engine.Use(newOTelMiddleware(normalizedDeps.Telemetry)) engine.Use(withInternalObservability(normalizedDeps.Logger, normalizedDeps.Telemetry)) engine.GET("/api/v1/internal/sessions/:device_session_id", handleGetSession(normalizedDeps.GetSession, cfg.RequestTimeout)) engine.GET("/api/v1/internal/users/:user_id/sessions", handleListUserSessions(normalizedDeps.ListUserSessions, cfg.RequestTimeout)) engine.POST("/api/v1/internal/sessions/:device_session_id/revoke", handleRevokeDeviceSession(normalizedDeps.RevokeDeviceSession, cfg.RequestTimeout)) engine.POST("/api/v1/internal/users/:user_id/sessions/revoke-all", handleRevokeAllUserSessions(normalizedDeps.RevokeAllUserSessions, cfg.RequestTimeout)) engine.POST("/api/v1/internal/user-blocks", handleBlockUser(normalizedDeps.BlockUser, cfg.RequestTimeout)) return engine, nil } func newOTelMiddleware(runtime *telemetry.Runtime) gin.HandlerFunc { options := []otelgin.Option{} if runtime != nil { options = append( options, otelgin.WithTracerProvider(runtime.TracerProvider()), otelgin.WithMeterProvider(runtime.MeterProvider()), ) } return otelgin.Middleware(internalHTTPServiceName, options...) } func handleGetSession(useCase GetSessionUseCase, timeout time.Duration) gin.HandlerFunc { return func(c *gin.Context) { callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout) defer cancel() result, err := useCase.Execute(callCtx, getsession.Input{ DeviceSessionID: c.Param("device_session_id"), }) if err != nil { abortWithProjection(c, projectInternalError(err)) return } if err := validateGetSessionResult(&result); err != nil { abortWithProjection(c, internalErrorProjection(fmt.Errorf("get session response: %w", err))) return } c.JSON(http.StatusOK, getSessionResponse{Session: toSessionResponseDTO(result.Session)}) } } func handleListUserSessions(useCase ListUserSessionsUseCase, timeout time.Duration) gin.HandlerFunc { return func(c *gin.Context) { callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout) defer cancel() result, err := useCase.Execute(callCtx, listusersessions.Input{ UserID: c.Param("user_id"), }) if err != nil { abortWithProjection(c, projectInternalError(err)) return } if err := validateListUserSessionsResult(&result); err != nil { abortWithProjection(c, internalErrorProjection(fmt.Errorf("list user sessions response: %w", err))) return } c.JSON(http.StatusOK, listUserSessionsResponse{Sessions: toSessionResponseDTOs(result.Sessions)}) } } func handleRevokeDeviceSession(useCase RevokeDeviceSessionUseCase, timeout time.Duration) gin.HandlerFunc { return func(c *gin.Context) { var request revokeDeviceSessionRequest if err := decodeJSONRequest(c.Request, &request); err != nil { abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error()))) return } if err := validateAuditRequest(request.ReasonCode, request.Actor); err != nil { abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error()))) return } callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout) defer cancel() result, err := useCase.Execute(callCtx, revokedevicesession.Input{ DeviceSessionID: c.Param("device_session_id"), ReasonCode: request.ReasonCode, ActorType: request.Actor.Type, ActorID: request.Actor.ID, }) if err != nil { abortWithProjection(c, projectInternalError(err)) return } if err := validateRevokeDeviceSessionResult(&result); err != nil { abortWithProjection(c, internalErrorProjection(fmt.Errorf("revoke device session response: %w", err))) return } c.JSON(http.StatusOK, revokeDeviceSessionResponse{ Outcome: result.Outcome, DeviceSessionID: result.DeviceSessionID, AffectedSessionCount: result.AffectedSessionCount, }) } } func handleRevokeAllUserSessions(useCase RevokeAllUserSessionsUseCase, timeout time.Duration) gin.HandlerFunc { return func(c *gin.Context) { var request revokeAllUserSessionsRequest if err := decodeJSONRequest(c.Request, &request); err != nil { abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error()))) return } if err := validateAuditRequest(request.ReasonCode, request.Actor); err != nil { abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error()))) return } callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout) defer cancel() result, err := useCase.Execute(callCtx, revokeallusersessions.Input{ UserID: c.Param("user_id"), ReasonCode: request.ReasonCode, ActorType: request.Actor.Type, ActorID: request.Actor.ID, }) if err != nil { abortWithProjection(c, projectInternalError(err)) return } if err := validateRevokeAllUserSessionsResult(&result); err != nil { abortWithProjection(c, internalErrorProjection(fmt.Errorf("revoke all user sessions response: %w", err))) return } c.JSON(http.StatusOK, revokeAllUserSessionsResponse{ Outcome: result.Outcome, UserID: result.UserID, AffectedSessionCount: result.AffectedSessionCount, AffectedDeviceSessionIDs: cloneStrings(result.AffectedDeviceSessionIDs), }) } } func handleBlockUser(useCase BlockUserUseCase, timeout time.Duration) gin.HandlerFunc { return func(c *gin.Context) { var request blockUserRequest if err := decodeJSONRequest(c.Request, &request); err != nil { abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error()))) return } if err := validateBlockUserRequest(&request); err != nil { abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error()))) return } callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout) defer cancel() result, err := useCase.Execute(callCtx, blockuser.Input{ UserID: request.UserID, Email: request.Email, ReasonCode: request.ReasonCode, ActorType: request.Actor.Type, ActorID: request.Actor.ID, }) if err != nil { abortWithProjection(c, projectInternalError(err)) return } if err := validateBlockUserResult(&result); err != nil { abortWithProjection(c, internalErrorProjection(fmt.Errorf("block user response: %w", err))) return } c.JSON(http.StatusOK, blockUserResponse{ Outcome: result.Outcome, SubjectKind: result.SubjectKind, SubjectValue: result.SubjectValue, AffectedSessionCount: result.AffectedSessionCount, AffectedDeviceSessionIDs: cloneStrings(result.AffectedDeviceSessionIDs), }) } } func toSessionResponseDTO(session shared.Session) sessionResponseDTO { return sessionResponseDTO{ DeviceSessionID: session.DeviceSessionID, UserID: session.UserID, ClientPublicKey: session.ClientPublicKey, Status: session.Status, CreatedAt: session.CreatedAt, RevokedAt: cloneStringPointer(session.RevokedAt), RevokeReasonCode: cloneStringPointer(session.RevokeReasonCode), RevokeActorType: cloneStringPointer(session.RevokeActorType), RevokeActorID: cloneStringPointer(session.RevokeActorID), } } func toSessionResponseDTOs(sessions []shared.Session) []sessionResponseDTO { result := make([]sessionResponseDTO, 0, len(sessions)) for _, session := range sessions { result = append(result, toSessionResponseDTO(session)) } return result } func cloneStrings(values []string) []string { result := make([]string, 0, len(values)) return append(result, values...) } func cloneStringPointer(value *string) *string { if value == nil { return nil } cloned := *value return &cloned } func validateAuditRequest(reasonCode string, actor actorRequest) error { if strings.TrimSpace(reasonCode) == "" { return errors.New("reason_code must not be empty") } if strings.TrimSpace(actor.Type) == "" { return errors.New("actor.type must not be empty") } return nil } func validateBlockUserRequest(request *blockUserRequest) error { if err := validateAuditRequest(request.ReasonCode, request.Actor); err != nil { return err } hasUserID := strings.TrimSpace(request.UserID) != "" hasEmail := strings.TrimSpace(request.Email) != "" switch { case hasUserID && hasEmail: return errors.New("exactly one of user_id or email must be provided") case !hasUserID && !hasEmail: return errors.New("exactly one of user_id or email must be provided") default: return nil } } func validateSessionDTO(session *shared.Session) error { switch { case strings.TrimSpace(session.DeviceSessionID) == "": return errors.New("session.device_session_id must not be empty") case strings.TrimSpace(session.UserID) == "": return errors.New("session.user_id must not be empty") case strings.TrimSpace(session.ClientPublicKey) == "": return errors.New("session.client_public_key must not be empty") case strings.TrimSpace(session.CreatedAt) == "": return errors.New("session.created_at must not be empty") } if _, err := time.Parse(time.RFC3339, session.CreatedAt); err != nil { return fmt.Errorf("session.created_at: %w", err) } switch session.Status { case "active": if session.RevokedAt != nil || session.RevokeReasonCode != nil || session.RevokeActorType != nil || session.RevokeActorID != nil { return errors.New("active session must not contain revoke metadata") } case "revoked": switch { case session.RevokedAt == nil || strings.TrimSpace(*session.RevokedAt) == "": return errors.New("revoked session must contain revoked_at") case session.RevokeReasonCode == nil || strings.TrimSpace(*session.RevokeReasonCode) == "": return errors.New("revoked session must contain revoke_reason_code") case session.RevokeActorType == nil || strings.TrimSpace(*session.RevokeActorType) == "": return errors.New("revoked session must contain revoke_actor_type") } if _, err := time.Parse(time.RFC3339, *session.RevokedAt); err != nil { return fmt.Errorf("session.revoked_at: %w", err) } default: return fmt.Errorf("session.status %q is unsupported", session.Status) } return nil } func validateGetSessionResult(result *getsession.Result) error { return validateSessionDTO(&result.Session) } func validateListUserSessionsResult(result *listusersessions.Result) error { if result.Sessions == nil { return errors.New("sessions must not be null") } for index := range result.Sessions { if err := validateSessionDTO(&result.Sessions[index]); err != nil { return fmt.Errorf("sessions[%d]: %w", index, err) } } return nil } func validateRevokeDeviceSessionResult(result *revokedevicesession.Result) error { switch result.Outcome { case "revoked": if result.AffectedSessionCount != 1 { return errors.New("revoked outcome must affect exactly one session") } case "already_revoked": if result.AffectedSessionCount != 0 { return errors.New("already_revoked outcome must affect zero sessions") } default: return fmt.Errorf("revoke device session outcome %q is unsupported", result.Outcome) } if strings.TrimSpace(result.DeviceSessionID) == "" { return errors.New("device_session_id must not be empty") } return nil } func validateRevokeAllUserSessionsResult(result *revokeallusersessions.Result) error { switch result.Outcome { case "revoked", "no_active_sessions": default: return fmt.Errorf("revoke all user sessions outcome %q is unsupported", result.Outcome) } if strings.TrimSpace(result.UserID) == "" { return errors.New("user_id must not be empty") } if result.AffectedSessionCount < 0 { return errors.New("affected_session_count must not be negative") } if result.AffectedDeviceSessionIDs == nil { return errors.New("affected_device_session_ids must not be null") } if int64(len(result.AffectedDeviceSessionIDs)) != result.AffectedSessionCount { return errors.New("affected_device_session_ids length must match affected_session_count") } for index, deviceSessionID := range result.AffectedDeviceSessionIDs { if strings.TrimSpace(deviceSessionID) == "" { return fmt.Errorf("affected_device_session_ids[%d] must not be empty", index) } } return nil } func validateBlockUserResult(result *blockuser.Result) error { switch result.Outcome { case "blocked", "already_blocked": default: return fmt.Errorf("block user outcome %q is unsupported", result.Outcome) } switch result.SubjectKind { case blockuser.SubjectKindUserID, blockuser.SubjectKindEmail: default: return fmt.Errorf("subject_kind %q is unsupported", result.SubjectKind) } if strings.TrimSpace(result.SubjectValue) == "" { return errors.New("subject_value must not be empty") } if result.AffectedSessionCount < 0 { return errors.New("affected_session_count must not be negative") } if result.AffectedDeviceSessionIDs == nil { return errors.New("affected_device_session_ids must not be null") } if int64(len(result.AffectedDeviceSessionIDs)) != result.AffectedSessionCount { return errors.New("affected_device_session_ids length must match affected_session_count") } for index, deviceSessionID := range result.AffectedDeviceSessionIDs { if strings.TrimSpace(deviceSessionID) == "" { return fmt.Errorf("affected_device_session_ids[%d] must not be empty", index) } } return nil } func projectInternalError(err error) shared.InternalErrorProjection { if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { return shared.ProjectInternalError(shared.ServiceUnavailable(err)) } return shared.ProjectInternalError(err) } func internalErrorProjection(err error) shared.InternalErrorProjection { return shared.ProjectInternalError(shared.InternalError(err)) }